Skip to content

feat(amsg): /cloudflare 链路载荷加密改用 Web Crypto,免 nodejs_compat 打包#19

Merged
Tosd0 merged 1 commit into
mainfrom
feat/amsg-cloudflare-webcrypto
Jul 16, 2026
Merged

feat(amsg): /cloudflare 链路载荷加密改用 Web Crypto,免 nodejs_compat 打包#19
Tosd0 merged 1 commit into
mainfrom
feat/amsg-cloudflare-webcrypto

Conversation

@Tosd0

@Tosd0 Tosd0 commented Jul 16, 2026

Copy link
Copy Markdown
Owner

改了什么

@rei-standard/amsg-server/cloudflare 此前打包必须开 nodejs_compat(载荷 AES 加密还在用 node crypto),现在整条单用户 / Cloudflare 链路不再引用任何 Node 内建模块:

改之前 改之后
esbuild --platform=neutral 打包 Could not resolve "crypto" exit 0,产物 80KB,node: 引用 0
Cloudflare Dashboard 粘贴部署 需要 compatibility_flags = ["nodejs_compat"] 免 flag(示例 wrangler.toml 已同步去掉)
  • lib/encryption.js 的 5 个导出(deriveUserEncryptionKey / encryptPayload / decryptPayload / encryptForStorage / decryptFromStorage)改为基于 globalThis.crypto.subtle全部从同步变 async,仓内所有调用点(含测试)已补 await
  • randomUUID 改从 runtime-neutral 的 webcrypto-utils.js 获取;该文件新增 hex / 标准 base64 编解码 helper。
  • 多租户入口(Netlify/Neon)行为不变;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 参考实现做跨实现互通:

  • node 加密 → 新实现解密;新实现加密 → node 解密(payload / storage 两种格式都覆盖)。
  • 派生 key 与 createHash('sha256') 逐字符相等;IV/tag 长度与编码布局断言。
  • base64url 字段、12 字节截断 tag 可解;<96 位 tag 拒收;篡改密文/authTag 必 reject。

全仓 5 包 500 测试全绿;neutral bundle 在 Node 里端到端冒烟通过(init → get-user-key(派生 key 与源码实现一致)→ 加密 schedule-message 201)。

发版

changeset amsg-server-webcrypto-encryption.md@rei-standard/amsg-server minor)。合并后 next 预发布线上一档(→ 2.6.0-next.2)。注意:对直接 import 这 5 个加密函数的外部消费者是 sync→async 的行为变更,迁移注意已写进 changeset。

https://claude.ai/code/session_01JmwcPeEKQoJaMrsiCBB5Dt

单用户 / 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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $O(N^2)$ string concatenation bottlenecks while removing dead fallback code, and using zero-copy 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.

Comment on lines +30 to +40
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, '');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performance Bottleneck & Dead Code Elimination

  1. 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 chunked String.fromCharCode.apply is significantly faster and avoids call stack overflow.
  2. Dead Code Elimination: Since the project requires Node ≥ 19 and Cloudflare Workers (both of which have global btoa built-in), the fallback to Node's Buffer is dead code and can be safely removed. This also prevents bundlers from attempting to polyfill Buffer in 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);
}

Comment on lines +53 to +56
return {
ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES),
authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES)
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)
};

@Tosd0
Tosd0 merged commit 301ff23 into main Jul 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant