Skip to content

refactor(amsg): 单用户/Cloudflare 加密改用 Web Crypto,免 nodejs_compat#18

Draft
Tosd0 wants to merge 1 commit into
mainfrom
worktree-amsg-cloudflare-webcrypto
Draft

refactor(amsg): 单用户/Cloudflare 加密改用 Web Crypto,免 nodejs_compat#18
Tosd0 wants to merge 1 commit into
mainfrom
worktree-amsg-cloudflare-webcrypto

Conversation

@Tosd0

@Tosd0 Tosd0 commented Jul 8, 2026

Copy link
Copy Markdown
Owner

改了什么

单用户 / Cloudflare 入口(@rei-standard/amsg-server/cloudflare)的载荷加密从 node crypto 换成 Web Crypto(globalThis.crypto.subtle)。整条子路径不再 import node 内置模块,Worker bundle 可纯 platform=neutral 打包,免开 nodejs_compat 兼容开关,直接粘进 Cloudflare Dashboard 即用(对齐 amsg-instant 的免 flag 体验)。

改后是怎样

方面 改前 改后
载荷加密实现 node cryptocreateCipheriv 等) Web Crypto subtle
Worker 打包 nodejs_compat 免任何兼容 flag
加密线格式 AES-256-GCM,载荷 base64 / 入库 hex iv:authTag:data 逐字节不变,旧数据照常解密
5 个加密导出 同步 async(返回 Promise,直接调用需 await

主要改动

  • 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 目标。

验收

  • grep node crypto 只剩 tenant/{token,context,blob-store}.js,单用户链一个不剩
  • ✅ 源码入口 & build 后 dist 都能纯 platform=neutral 打包(esbuild exit 0,产物无 node: 引用)
  • ✅ 全套 server 测试 133 passed(含多租户)
  • ✅ 加了 changeset;./cloudflare 导出子路径不变

https://claude.ai/code/session_01UrpqkwZBr8NzkmRhghEotk

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

@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 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.

Comment on lines +31 to +33
async function importAesKey(hexKey) {
return subtle.importKey('raw', hexToBytes(hexKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}

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

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']);
}

Comment on lines +54 to 60
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));
}

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

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

Comment on lines +104 to 110
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);
}

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

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.

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

Comment on lines +80 to +86
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;
}

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

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;
}

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