refactor(amsg): 单用户/Cloudflare 加密改用 Web Crypto,免 nodejs_compat#18
Conversation
单用户 / Cloudflare 入口(@rei-standard/amsg-server/cloudflare)的载荷加密从
node crypto 换成 Web Crypto(globalThis.crypto.subtle),整条子路径不再 import
node 内置模块,Worker bundle 可纯 platform=neutral 打包,免开 nodejs_compat
兼容开关,直接粘进 Cloudflare Dashboard 即用。
- lib/encryption.js:5 个导出全部改为 async,AES-256-GCM 用 subtle 实现;线
格式(载荷 base64、入库 hex iv:authTag:data、12/16 字节 IV、sha256 派生
key)逐字节保持不变,旧数据照常解密。
- lib/webcrypto-utils.js:补 hex / 标准 base64 编解码 helper。
- 两处 randomUUID 改从 webcrypto-utils 引,去掉 node crypto import。
- 全仓补齐 await 涟漪(handlers + run-tick + message-processor),多租户主入口
共用的 encryption 也一并跟进。
- 新增 test/encryption.test.mjs:跨 node/Web Crypto 互通、派生 key 等价、
round-trip、篡改即抛,把线格式钉死当回归守卫。
多租户专用的 tenant/{token,blob-store,context}.js 仍用 node crypto,不在
cloudflare 图内,不影响免 flag 目标。
Claude-Session: https://claude.ai/code/session_01UrpqkwZBr8NzkmRhghEotk
There was a problem hiding this comment.
Code Review
This pull request migrates the encryption utility library from Node's crypto module to pure Web Crypto (globalThis.crypto.subtle), removing the need for the nodejs_compat flag in Cloudflare Workers. As a result, encryption and decryption operations are now asynchronous, and the PR correctly updates all handlers, message processors, and test suites to await these promises. The review feedback focuses on improving robustness by adding defensive input validation checks to several cryptographic utility functions (importAesKey, decryptPayload, decryptFromStorage, and hexToBytes) to prevent cryptic runtime errors or silent data truncation when malformed inputs are passed.
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.
| async function importAesKey(hexKey) { | ||
| return subtle.importKey('raw', hexToBytes(hexKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); | ||
| } |
There was a problem hiding this comment.
The importAesKey function does not validate that hexKey is a valid 64-character hex string (representing a 256-bit AES key). If an invalid key length is passed, subtle.importKey will throw a generic DOMException. Adding a validation check here improves error clarity and robustness.
async function importAesKey(hexKey) {
if (typeof hexKey !== 'string' || hexKey.length !== 64) {
throw new Error('Invalid encryption key: must be a 64-character hex string');
}
return subtle.importKey('raw', hexToBytes(hexKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}| export async function decryptPayload(encryptedPayload, encryptionKey) { | ||
| const { iv, authTag, encryptedData } = encryptedPayload; | ||
|
|
||
| const decipher = createDecipheriv( | ||
| 'aes-256-gcm', | ||
| Buffer.from(encryptionKey, 'hex'), | ||
| Buffer.from(iv, 'base64') | ||
| ); | ||
|
|
||
| decipher.setAuthTag(Buffer.from(authTag, 'base64')); | ||
|
|
||
| const decrypted = Buffer.concat([ | ||
| decipher.update(Buffer.from(encryptedData, 'base64')), | ||
| decipher.final() | ||
| ]); | ||
|
|
||
| return JSON.parse(decrypted.toString('utf8')); | ||
| const key = await importAesKey(encryptionKey); | ||
| const sealed = concatBytes(base64ToBytes(encryptedData), base64ToBytes(authTag)); | ||
| const plain = await subtle.decrypt({ name: 'AES-GCM', iv: base64ToBytes(iv), tagLength: 128 }, key, sealed); | ||
| return JSON.parse(utf8Decode(plain)); | ||
| } |
There was a problem hiding this comment.
The decryptPayload function does not validate that encryptedPayload is a non-null object or that it contains the required iv, authTag, and encryptedData properties. If any of these are missing or if the payload is null/undefined, it will throw a cryptic TypeError. Adding defensive checks here ensures robust error handling.
export async function decryptPayload(encryptedPayload, encryptionKey) {
if (!encryptedPayload || typeof encryptedPayload !== 'object') {
throw new Error('Invalid encrypted payload: must be an object');
}
const { iv, authTag, encryptedData } = encryptedPayload;
if (!iv || !authTag || !encryptedData) {
throw new Error('Invalid encrypted payload: missing iv, authTag, or encryptedData');
}
const key = await importAesKey(encryptionKey);
const sealed = concatBytes(base64ToBytes(encryptedData), base64ToBytes(authTag));
const plain = await subtle.decrypt({ name: 'AES-GCM', iv: base64ToBytes(iv), tagLength: 128 }, key, sealed);
return JSON.parse(utf8Decode(plain));
}| export async function decryptFromStorage(encryptedText, encryptionKey) { | ||
| const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':'); | ||
| const decipher = createDecipheriv( | ||
| 'aes-256-gcm', | ||
| Buffer.from(encryptionKey, 'hex'), | ||
| Buffer.from(ivHex, 'hex') | ||
| ); | ||
| decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); | ||
| return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8'); | ||
| const key = await importAesKey(encryptionKey); | ||
| const sealed = concatBytes(hexToBytes(encryptedDataHex), hexToBytes(authTagHex)); | ||
| const plain = await subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(ivHex), tagLength: 128 }, key, sealed); | ||
| return utf8Decode(plain); | ||
| } |
There was a problem hiding this comment.
The decryptFromStorage function does not validate that encryptedText is a valid string or that it contains exactly three colon-separated parts (iv:authTag:encryptedData). If the input is malformed or missing parts, it will cause downstream helper functions like hexToBytes to throw cryptic TypeErrors. Adding defensive checks here ensures robust error handling.
| export async function decryptFromStorage(encryptedText, encryptionKey) { | |
| const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':'); | |
| const decipher = createDecipheriv( | |
| 'aes-256-gcm', | |
| Buffer.from(encryptionKey, 'hex'), | |
| Buffer.from(ivHex, 'hex') | |
| ); | |
| decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); | |
| return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8'); | |
| const key = await importAesKey(encryptionKey); | |
| const sealed = concatBytes(hexToBytes(encryptedDataHex), hexToBytes(authTagHex)); | |
| const plain = await subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(ivHex), tagLength: 128 }, key, sealed); | |
| return utf8Decode(plain); | |
| } | |
| export async function decryptFromStorage(encryptedText, encryptionKey) { | |
| if (typeof encryptedText !== 'string') { | |
| throw new Error('Invalid encrypted text: must be a string'); | |
| } | |
| const parts = encryptedText.split(':'); | |
| if (parts.length !== 3) { | |
| throw new Error('Invalid encrypted text format: expected iv:authTag:encryptedData'); | |
| } | |
| const [ivHex, authTagHex, encryptedDataHex] = parts; | |
| const key = await importAesKey(encryptionKey); | |
| const sealed = concatBytes(hexToBytes(encryptedDataHex), hexToBytes(authTagHex)); | |
| const plain = await subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(ivHex), tagLength: 128 }, key, sealed); | |
| return utf8Decode(plain); | |
| } |
| export function hexToBytes(hex) { | ||
| const out = new Uint8Array(hex.length / 2); | ||
| for (let i = 0; i < out.length; i++) { | ||
| out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); | ||
| } | ||
| return out; | ||
| } |
There was a problem hiding this comment.
The hexToBytes function does not validate that the input hex is a valid even-length hex string. If an odd-length string is passed, hex.length / 2 results in a float, which is truncated to an integer when creating the Uint8Array, silently ignoring the last character. For cryptographic operations, any malformed or odd-length hex string should be rejected immediately to prevent silent data truncation or corruption.
export function hexToBytes(hex) {
if (typeof hex !== 'string' || hex.length % 2 !== 0) {
throw new Error('Invalid hex string: must be an even-length string');
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
const val = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(val)) {
throw new Error('Invalid hex character');
}
out[i] = val;
}
return out;
}
改了什么
单用户 / Cloudflare 入口(
@rei-standard/amsg-server/cloudflare)的载荷加密从 nodecrypto换成 Web Crypto(globalThis.crypto.subtle)。整条子路径不再 import node 内置模块,Worker bundle 可纯platform=neutral打包,免开nodejs_compat兼容开关,直接粘进 Cloudflare Dashboard 即用(对齐amsg-instant的免 flag 体验)。改后是怎样
crypto(createCipheriv等)subtlenodejs_compativ:authTag:dataawait)主要改动
lib/encryption.js:5 个导出改 async,用subtle实现 AES-256-GCM。保留 12/16 字节 IV、base64/hex 编码、sha256(masterKey+userId)派生 key,authTag 在加密时从密文尾切出、解密时拼回。lib/webcrypto-utils.js:补 hex / 标准 base64 编解码 helper(不用Buffer)。randomUUID改从webcrypto-utils引,去掉 node crypto import。await涟漪(handlers +run-tick+message-processor);多租户主入口共用的encryption一并跟进。test/encryption.test.mjs:跨 node ↔ Web Crypto 双向互通、派生 key 等价、round-trip(中文/emoji/空串)、篡改即抛——把线格式钉死当回归守卫。多租户专用的
tenant/{token,blob-store,context}.js仍用 node crypto,不在 cloudflare 图内,不影响免 flag 目标。验收
grepnode crypto 只剩tenant/{token,context,blob-store}.js,单用户链一个不剩platform=neutral打包(esbuildexit 0,产物无node:引用)./cloudflare导出子路径不变https://claude.ai/code/session_01UrpqkwZBr8NzkmRhghEotk