diff --git a/.changeset/amsg-server-cloudflare-webcrypto.md b/.changeset/amsg-server-cloudflare-webcrypto.md new file mode 100644 index 0000000..aabce35 --- /dev/null +++ b/.changeset/amsg-server-cloudflare-webcrypto.md @@ -0,0 +1,7 @@ +--- +"@rei-standard/amsg-server": minor +--- + +单用户 / Cloudflare 入口(`@rei-standard/amsg-server/cloudflare`)的载荷加密改用 Web Crypto(`globalThis.crypto.subtle`),不再依赖 Node 的 `crypto`。整条子路径现在只用 WHATWG 标准 API,Worker bundle 打包免开 `nodejs_compat` 兼容开关,可直接粘进 Cloudflare Dashboard。 + +加密线格式(AES-256-GCM、载荷 base64、入库 hex `iv:authTag:data`)保持不变,旧数据照常解密。因实现改为异步,`@rei-standard/amsg-server/cloudflare` 重新导出的 `deriveUserEncryptionKey`、`decryptPayload`、`encryptForStorage`、`decryptFromStorage` 现在返回 Promise,直接调用需 `await`。 diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare.js b/packages/rei-standard-amsg/server/src/server/cloudflare.js index 5d694e2..d34f4fc 100644 --- a/packages/rei-standard-amsg/server/src/server/cloudflare.js +++ b/packages/rei-standard-amsg/server/src/server/cloudflare.js @@ -11,8 +11,9 @@ * the optional `pg` / `@neondatabase/serverless` peers) bundling cleanly: the * root entry pulls those in through `createReiServer`, this one does not. * - * node:crypto is the only Node builtin in this subgraph; enable it on Workers - * with `compatibility_flags = ["nodejs_compat"]` (see the example wrangler.toml). + * The whole subgraph is pure Web Crypto (`globalThis.crypto.subtle`) with no + * Node builtins, so a Worker bundle resolves cleanly without any compatibility + * flag — `nodejs_compat` is not required. */ export { createSingleUserCloudflareWorker } from './cloudflare/single-user-worker.js'; diff --git a/packages/rei-standard-amsg/server/src/server/handlers/get-user-key.js b/packages/rei-standard-amsg/server/src/server/handlers/get-user-key.js index 3e56f2e..4087b90 100644 --- a/packages/rei-standard-amsg/server/src/server/handlers/get-user-key.js +++ b/packages/rei-standard-amsg/server/src/server/handlers/get-user-key.js @@ -35,12 +35,14 @@ export function createGetUserKeyHandler(ctx) { }; } + const userKey = await deriveUserEncryptionKey(userId, masterKey); + return { status: 200, body: { success: true, data: { - userKey: deriveUserEncryptionKey(userId, masterKey), + userKey, version: 1 } } diff --git a/packages/rei-standard-amsg/server/src/server/handlers/messages.js b/packages/rei-standard-amsg/server/src/server/handlers/messages.js index 38569d4..4b77954 100644 --- a/packages/rei-standard-amsg/server/src/server/handlers/messages.js +++ b/packages/rei-standard-amsg/server/src/server/handlers/messages.js @@ -50,10 +50,10 @@ export function createMessagesHandler(ctx) { const { tasks, total } = await db.listTasks(userId, { status, limit, offset }); - const userKey = deriveUserEncryptionKey(userId, masterKey); + const userKey = await deriveUserEncryptionKey(userId, masterKey); - const decryptedTasks = tasks.map(task => { - const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + const decryptedTasks = await Promise.all(tasks.map(async task => { + const decrypted = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey)); return { id: task.id, uuid: task.uuid, @@ -67,13 +67,13 @@ export function createMessagesHandler(ctx) { createdAt: task.created_at, updatedAt: task.updated_at }; - }); + })); const responsePayload = { tasks: decryptedTasks, pagination: { total, limit, offset, hasMore: offset + limit < total } }; - const encryptedResponse = encryptPayload(responsePayload, userKey); + const encryptedResponse = await encryptPayload(responsePayload, userKey); return { status: 200, diff --git a/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js b/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js index 4a5d632..61de066 100644 --- a/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js +++ b/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js @@ -6,7 +6,7 @@ * @returns {{ POST: function }} */ -import { randomUUID } from 'crypto'; +import { randomUUID } from '../lib/webcrypto-utils.js'; import { deriveUserEncryptionKey, decryptPayload, encryptForStorage } from '../lib/encryption.js'; import { isUniqueViolation } from '../lib/db-errors.js'; import { getHeader, isPlainObject, parseEncryptedBody } from '../lib/request.js'; @@ -50,8 +50,8 @@ export function createScheduleMessageHandler(ctx) { let payload; try { - const userKey = deriveUserEncryptionKey(userId, masterKey); - payload = decryptPayload(encryptedBody, userKey); + const userKey = await deriveUserEncryptionKey(userId, masterKey); + payload = await decryptPayload(encryptedBody, userKey); } catch (error) { if (error instanceof SyntaxError) { return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } }; @@ -76,7 +76,7 @@ export function createScheduleMessageHandler(ctx) { } const taskUuid = payload.uuid || randomUUID(); - const userKey = deriveUserEncryptionKey(userId, masterKey); + const userKey = await deriveUserEncryptionKey(userId, masterKey); const fullTaskData = { contactName: payload.contactName, @@ -104,7 +104,7 @@ export function createScheduleMessageHandler(ctx) { metadata: payload.metadata || {} }; - const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey); + const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey); /** * In-server instant path. Delivers an instant message through this diff --git a/packages/rei-standard-amsg/server/src/server/handlers/update-message.js b/packages/rei-standard-amsg/server/src/server/handlers/update-message.js index df38441..6c3126b 100644 --- a/packages/rei-standard-amsg/server/src/server/handlers/update-message.js +++ b/packages/rei-standard-amsg/server/src/server/handlers/update-message.js @@ -52,11 +52,11 @@ export function createUpdateMessageHandler(ctx) { } const encryptedBody = parsedBody.data; - const userKey = deriveUserEncryptionKey(userId, masterKey); + const userKey = await deriveUserEncryptionKey(userId, masterKey); let updates; try { - updates = decryptPayload(encryptedBody, userKey); + updates = await decryptPayload(encryptedBody, userKey); } catch (_error) { return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } }; } @@ -132,7 +132,7 @@ export function createUpdateMessageHandler(ctx) { return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } }; } - const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey)); + const existingData = JSON.parse(await decryptFromStorage(existingTask.encrypted_payload, userKey)); // When the caller switches prompt source (completePrompt ↔ messages), // null out the other so storage stays one-of (matches schedule-message @@ -161,7 +161,7 @@ export function createUpdateMessageHandler(ctx) { ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null }) }; - const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey); + const encryptedPayload = await encryptForStorage(JSON.stringify(updatedData), userKey); const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined; const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields); diff --git a/packages/rei-standard-amsg/server/src/server/lib/encryption.js b/packages/rei-standard-amsg/server/src/server/lib/encryption.js index 775dfb8..9350427 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/encryption.js +++ b/packages/rei-standard-amsg/server/src/server/lib/encryption.js @@ -1,24 +1,47 @@ /** - * Encryption utility library (SDK version) - * ReiStandard SDK v2.0.1 + * Encryption utility library (Web Crypto version) + * ReiStandard SDK * - * Wraps AES-256-GCM operations for request/response and storage encryption. + * AES-256-GCM for request/response and storage encryption. Built on + * `globalThis.crypto.subtle` so it runs on any Web Crypto runtime (Cloudflare + * Workers, Vercel/Netlify Edge, Deno, Bun, Node ≥ 19) with no Node builtins. + * That lets the single-user / Cloudflare entry bundle without `nodejs_compat`. + * + * Wire format is byte-for-byte identical to the previous node:crypto version: + * payload uses standard base64 with a separate `authTag`; storage uses hex in + * `iv:authTag:encryptedData`. Web Crypto appends the GCM tag to the ciphertext, + * so these functions split it off on encrypt and re-join it on decrypt. */ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'; +import { + utf8, + utf8Decode, + randomBytes, + concatBytes, + bytesToHex, + hexToBytes, + bytesToBase64, + base64ToBytes, +} from './webcrypto-utils.js'; + +const subtle = globalThis.crypto.subtle; +const TAG_LEN = 16; // AES-GCM auth tag length in bytes (tagLength: 128) + +/** Import a 64-char hex key as an AES-256-GCM CryptoKey. */ +async function importAesKey(hexKey) { + return subtle.importKey('raw', hexToBytes(hexKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} /** * Derive a user-specific encryption key from the master key. * * @param {string} userId - Unique user identifier. * @param {string} masterKey - 64-char hex master key. - * @returns {string} 64-char hex key. + * @returns {Promise} 64-char hex key. */ -export function deriveUserEncryptionKey(userId, masterKey) { - return createHash('sha256') - .update(masterKey + userId) - .digest('hex') - .slice(0, 64); +export async function deriveUserEncryptionKey(userId, masterKey) { + const digest = await subtle.digest('SHA-256', utf8(masterKey + userId)); + return bytesToHex(new Uint8Array(digest)).slice(0, 64); } /** @@ -26,25 +49,14 @@ export function deriveUserEncryptionKey(userId, masterKey) { * * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload * @param {string} encryptionKey - 64-char hex key. - * @returns {Object} Decrypted JSON object. + * @returns {Promise} Decrypted JSON object. */ -export function decryptPayload(encryptedPayload, encryptionKey) { +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)); } /** @@ -52,19 +64,17 @@ export function decryptPayload(encryptedPayload, encryptionKey) { * * @param {string|Object} payload * @param {string} encryptionKey - 64-char hex key. - * @returns {{ iv: string, authTag: string, encryptedData: string }} + * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>} */ -export function encryptPayload(payload, encryptionKey) { +export async function encryptPayload(payload, encryptionKey) { const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload); const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv); - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - const authTag = cipher.getAuthTag(); - + const key = await importAesKey(encryptionKey); + const sealed = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, utf8(plaintext))); return { - iv: iv.toString('base64'), - authTag: authTag.toString('base64'), - encryptedData: encrypted.toString('base64') + iv: bytesToBase64(iv), + authTag: bytesToBase64(sealed.slice(sealed.length - TAG_LEN)), + encryptedData: bytesToBase64(sealed.slice(0, sealed.length - TAG_LEN)), }; } @@ -73,14 +83,15 @@ export function encryptPayload(payload, encryptionKey) { * * @param {string} text - Plaintext string. * @param {string} encryptionKey - 64-char hex key. - * @returns {string} Format: iv:authTag:encryptedData + * @returns {Promise} Format: iv:authTag:encryptedData */ -export function encryptForStorage(text, encryptionKey) { +export async function encryptForStorage(text, encryptionKey) { const iv = randomBytes(16); - const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv); - const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex'); - const authTag = cipher.getAuthTag(); - return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; + const key = await importAesKey(encryptionKey); + const sealed = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, utf8(text))); + const data = sealed.slice(0, sealed.length - TAG_LEN); + const tag = sealed.slice(sealed.length - TAG_LEN); + return `${bytesToHex(iv)}:${bytesToHex(tag)}:${bytesToHex(data)}`; } /** @@ -88,15 +99,12 @@ export function encryptForStorage(text, encryptionKey) { * * @param {string} encryptedText - Format: iv:authTag:encryptedData * @param {string} encryptionKey - 64-char hex key. - * @returns {string} Plaintext string. + * @returns {Promise} Plaintext string. */ -export function decryptFromStorage(encryptedText, encryptionKey) { +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); } diff --git a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js index b6f9146..c90ab47 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js +++ b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js @@ -19,7 +19,7 @@ * count only (reasoning is an auxiliary push, not a sentence). */ -import { randomUUID } from 'crypto'; +import { randomUUID } from './webcrypto-utils.js'; import { buildContentPush, buildReasoningPush, @@ -104,8 +104,8 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { return { success: false, messagesSent: 0, error: 'TENANT_MASTER_KEY_MISSING' }; } - const userKey = deriveUserEncryptionKey(task.user_id, masterKey); - const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + const userKey = await deriveUserEncryptionKey(task.user_id, masterKey); + const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey)); let messageContent; /** @type {unknown} */ diff --git a/packages/rei-standard-amsg/server/src/server/lib/run-tick.js b/packages/rei-standard-amsg/server/src/server/lib/run-tick.js index 7ac75f4..b98218b 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/run-tick.js +++ b/packages/rei-standard-amsg/server/src/server/lib/run-tick.js @@ -75,8 +75,8 @@ export async function runScheduledTick(ctx) { } try { - const userKey = deriveUserEncryptionKey(task.user_id, masterKey); - const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + const userKey = await deriveUserEncryptionKey(task.user_id, masterKey); + const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey)); if (decryptedPayload.recurrenceType === 'none') { await db.deleteTaskById(task.id); diff --git a/packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js b/packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js index e3f44e9..8c81f95 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js +++ b/packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js @@ -46,6 +46,45 @@ export function jsonToBase64Url(value) { return bytesToBase64Url(utf8(JSON.stringify(value))); } +/** Encode bytes as standard base64 (with `=` padding). */ +export function bytesToBase64(buf) { + const bytes = toUint8(buf); + let bin = ''; + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]); + } + return btoa(bin); +} + +/** Decode a standard base64 string into a Uint8Array. */ +export function base64ToBytes(str) { + const bin = atob(str); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + out[i] = bin.charCodeAt(i); + } + return out; +} + +/** Encode bytes as lowercase hex. */ +export function bytesToHex(buf) { + const bytes = toUint8(buf); + let hex = ''; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return hex; +} + +/** Decode a hex string into a Uint8Array. */ +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; +} + /** * Constant-time byte comparison. Returns true iff `a` and `b` are equal-length * sequences with the same bytes. Length is intentionally NOT secret — early diff --git a/packages/rei-standard-amsg/server/test/encryption.test.mjs b/packages/rei-standard-amsg/server/test/encryption.test.mjs new file mode 100644 index 0000000..b40512b --- /dev/null +++ b/packages/rei-standard-amsg/server/test/encryption.test.mjs @@ -0,0 +1,159 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { + deriveUserEncryptionKey, + encryptPayload, + decryptPayload, + encryptForStorage, + decryptFromStorage, +} from '../src/server/lib/encryption.js'; + +// These tests pin the wire format of the Web Crypto encryption library so a +// future change to the algorithm, IV length, encoding, or auth-tag handling +// can't silently break interop with data written by the previous node:crypto +// implementation. They run on Node, which has BOTH node:crypto and Web Crypto, +// so the node:crypto helpers below stand in for the old implementation and the +// tests prove the two produce/consume the exact same bytes. + +const MASTER_KEY = 'a'.repeat(64); +const USER_ID = '550e8400-e29b-41d4-a716-446655440000'; + +// ─── node:crypto reference implementation (the old encryption.js, verbatim) ─── + +/** sha256(masterKey + userId) hex, first 64 chars. */ +function nodeDeriveKey(userId, masterKey) { + return crypto.createHash('sha256').update(masterKey + userId).digest('hex').slice(0, 64); +} + +/** Old payload format: 12-byte IV, separate authTag, standard base64. */ +function nodeEncryptPayload(payload, keyHex) { + const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return { + iv: iv.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + encryptedData: encrypted.toString('base64'), + }; +} + +function nodeDecryptPayload({ iv, authTag, encryptedData }, keyHex) { + const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(authTag, 'base64')); + const decrypted = Buffer.concat([decipher.update(Buffer.from(encryptedData, 'base64')), decipher.final()]); + return decrypted.toString('utf8'); +} + +/** Old storage format: 16-byte IV, hex, colon-separated iv:authTag:data. */ +function nodeEncryptForStorage(text, keyHex) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), iv); + const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex'); + return `${iv.toString('hex')}:${cipher.getAuthTag().toString('hex')}:${encrypted}`; +} + +function nodeDecryptFromStorage(text, keyHex) { + const [ivHex, tagHex, dataHex] = text.split(':'); + const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), Buffer.from(ivHex, 'hex')); + decipher.setAuthTag(Buffer.from(tagHex, 'hex')); + return decipher.update(dataHex, 'hex', 'utf8') + decipher.final('utf8'); +} + +const SAMPLES = ['hello world', '楪同学的中文消息', 'emoji 🎉🔒✨ mixed 中英', '']; + +// ─── 1. Derived key equivalence ────────────────────────────────────────────── + +test('deriveUserEncryptionKey matches the node:crypto sha256 derivation', async () => { + for (const userId of [USER_ID, 'other-user', '']) { + const web = await deriveUserEncryptionKey(userId, MASTER_KEY); + const node = nodeDeriveKey(userId, MASTER_KEY); + assert.equal(web, node, `derived key must match for userId=${JSON.stringify(userId)}`); + assert.match(web, /^[0-9a-f]{64}$/); + } +}); + +// ─── 2. Cross-implementation interop (pins the wire format) ─────────────────── + +test('payload: node:crypto ciphertext decrypts with the Web Crypto decryptPayload', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const obj = { contactName: '楪', messageType: 'fixed', n: 42 }; + const nodeCiphertext = nodeEncryptPayload(obj, key); + const decrypted = await decryptPayload(nodeCiphertext, key); + assert.deepEqual(decrypted, obj); +}); + +test('payload: Web Crypto encryptPayload output decrypts with node:crypto', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const obj = { hello: 'world 🎉', arr: [1, 2, 3] }; + const webCiphertext = await encryptPayload(obj, key); + // Shape must be exactly what the old format promised. + assert.deepEqual(Object.keys(webCiphertext).sort(), ['authTag', 'encryptedData', 'iv']); + assert.equal(Buffer.from(webCiphertext.iv, 'base64').length, 12, 'payload IV stays 12 bytes'); + assert.equal(Buffer.from(webCiphertext.authTag, 'base64').length, 16, 'auth tag is 16 bytes'); + assert.deepEqual(JSON.parse(nodeDecryptPayload(webCiphertext, key)), obj); +}); + +test('storage: node:crypto ciphertext decrypts with the Web Crypto decryptFromStorage', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + for (const text of SAMPLES) { + const nodeCiphertext = nodeEncryptForStorage(text, key); + assert.equal(await decryptFromStorage(nodeCiphertext, key), text); + } +}); + +test('storage: Web Crypto encryptForStorage output decrypts with node:crypto', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + for (const text of SAMPLES) { + const webCiphertext = await encryptForStorage(text, key); + const [ivHex, tagHex] = webCiphertext.split(':'); + assert.equal(Buffer.from(ivHex, 'hex').length, 16, 'storage IV stays 16 bytes'); + assert.equal(Buffer.from(tagHex, 'hex').length, 16, 'auth tag is 16 bytes'); + assert.equal(nodeDecryptFromStorage(webCiphertext, key), text); + } +}); + +// ─── 3. Round-trips (incl. Chinese, emoji, empty-string edge) ───────────────── + +test('encryptPayload → decryptPayload round-trips JSON objects', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + for (const value of [{ a: 1, msg: '楪同学' }, { text: 'emoji 🔒✨', arr: [1, 2] }, { empty: '' }]) { + const round = await decryptPayload(await encryptPayload(value, key), key); + assert.deepEqual(round, value); + } +}); + +test('encryptForStorage → decryptFromStorage round-trips all samples', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + for (const text of SAMPLES) { + assert.equal(await decryptFromStorage(await encryptForStorage(text, key), key), text); + } +}); + +// ─── 4. Tampering is rejected (GCM integrity holds) ─────────────────────────── + +test('decryptPayload rejects a tampered auth tag', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const sealed = await encryptPayload({ secret: 'value' }, key); + const tag = Buffer.from(sealed.authTag, 'base64'); + tag[0] ^= 0xff; + await assert.rejects(decryptPayload({ ...sealed, authTag: tag.toString('base64') }, key)); +}); + +test('decryptFromStorage rejects a tampered ciphertext byte', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const sealed = await encryptForStorage('sensitive data', key); + const [ivHex, tagHex, dataHex] = sealed.split(':'); + const data = Buffer.from(dataHex, 'hex'); + data[0] ^= 0xff; + const tampered = `${ivHex}:${tagHex}:${data.toString('hex')}`; + await assert.rejects(decryptFromStorage(tampered, key)); +}); + +test('decryptFromStorage rejects the wrong key', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const wrongKey = await deriveUserEncryptionKey('someone-else', MASTER_KEY); + const sealed = await encryptForStorage('secret', key); + await assert.rejects(decryptFromStorage(sealed, wrongKey)); +}); diff --git a/packages/rei-standard-amsg/server/test/message-processor.test.mjs b/packages/rei-standard-amsg/server/test/message-processor.test.mjs index 586e83a..71e0bdc 100644 --- a/packages/rei-standard-amsg/server/test/message-processor.test.mjs +++ b/packages/rei-standard-amsg/server/test/message-processor.test.mjs @@ -7,9 +7,9 @@ import { validateScheduleMessagePayload, validateLlmMessagesArray, validateSplit const TEST_USER_ID = '550e8400-e29b-41d4-a716-446655440000'; const TEST_MASTER_KEY = 'a'.repeat(64); -function createEncryptedTask(payload) { - const userKey = deriveUserEncryptionKey(TEST_USER_ID, TEST_MASTER_KEY); - const encryptedPayload = encryptForStorage(JSON.stringify(payload), userKey); +async function createEncryptedTask(payload) { + const userKey = await deriveUserEncryptionKey(TEST_USER_ID, TEST_MASTER_KEY); + const encryptedPayload = await encryptForStorage(JSON.stringify(payload), userKey); return { id: 1, user_id: TEST_USER_ID, @@ -36,7 +36,7 @@ function createContext(sendNotificationSpy = async () => {}) { describe('message processor AI apiUrl handling', () => { it('normalizes trailing slashes before calling fetch', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'say hi', @@ -79,7 +79,7 @@ describe('message processor AI apiUrl handling', () => { }); it('passes max_tokens only when maxTokens is provided', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'say hi', @@ -116,7 +116,7 @@ describe('message processor AI apiUrl handling', () => { }); it('returns a clear error when AI endpoint responds with 405', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'say hi', @@ -147,7 +147,7 @@ describe('message processor AI apiUrl handling', () => { }); it('auto-completes a bare host to /v1/chat/completions before calling fetch', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'say hi', @@ -179,7 +179,7 @@ describe('message processor AI apiUrl handling', () => { }); it('auto-appends /chat/completions when apiUrl ends in /v1 (no double v1)', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'say hi', @@ -362,7 +362,7 @@ describe('messages array support', () => { { role: 'assistant', content: 'sure' }, { role: 'user', content: 'continue' }, ]; - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', messages, @@ -396,7 +396,7 @@ describe('messages array support', () => { }); it('LLM call: legacy completePrompt path still wraps and defaults temperature 0.8', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'legacy hi', @@ -429,7 +429,7 @@ describe('messages array support', () => { }); it('LLM call: messages mode does NOT inject default temperature when caller omits it', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', messages: [{ role: 'user', content: 'hi' }], @@ -520,7 +520,7 @@ describe('splitPattern support', () => { }); it('processSingleMessage: default regex when splitPattern absent (back-compat)', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'x', @@ -553,7 +553,7 @@ describe('splitPattern support', () => { }); it('processSingleMessage: uses caller-supplied splitPattern (string)', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'x', @@ -586,7 +586,7 @@ describe('splitPattern support', () => { }); it('processSingleMessage: emits ContentPush with messageKind/sessionId (v2.4.0 schema)', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'x', @@ -638,7 +638,7 @@ describe('splitPattern support', () => { }); it('processSingleMessage: auto-emits ReasoningPush before ContentPush when LLM returns reasoning_content', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'x', @@ -688,7 +688,7 @@ describe('splitPattern support', () => { }); it('processSingleMessage: does NOT emit ReasoningPush for fixed messageType (no LLM call)', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'fixed', userMessage: '固定消息', @@ -709,7 +709,7 @@ describe('splitPattern support', () => { }); it('processSingleMessage: messageType:"instant" routes to source:"instant" (via-server instant path)', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'instant', completePrompt: 'x', @@ -744,7 +744,7 @@ describe('splitPattern support', () => { // Pin the v2.4.0 messageId format: `msg_task__` for scheduled // rows, so a retry produces the same id for the same (task, sentence) // pair and downstream dedupers can key on it. - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'x', @@ -786,8 +786,8 @@ describe('splitPattern support', () => { // The legacy in-server instant path can receive a task row with // `id == null`. In that case there's no stable key to derive the // sessionId/messageId from, so we fall back to UUIDs. - const userKey = deriveUserEncryptionKey(TEST_USER_ID, TEST_MASTER_KEY); - const encryptedPayload = encryptForStorage(JSON.stringify({ + const userKey = await deriveUserEncryptionKey(TEST_USER_ID, TEST_MASTER_KEY); + const encryptedPayload = await encryptForStorage(JSON.stringify({ contactName: 'Rei', messageType: 'instant', completePrompt: 'x', @@ -819,7 +819,7 @@ describe('splitPattern support', () => { }); it('processSingleMessage: cascades string[] splitPattern in order', async () => { - const task = createEncryptedTask({ + const task = await createEncryptedTask({ contactName: 'Rei', messageType: 'prompted', completePrompt: 'x', diff --git a/packages/rei-standard-amsg/server/test/run-tick.test.mjs b/packages/rei-standard-amsg/server/test/run-tick.test.mjs index 31d7cb7..a52d2cb 100644 --- a/packages/rei-standard-amsg/server/test/run-tick.test.mjs +++ b/packages/rei-standard-amsg/server/test/run-tick.test.mjs @@ -10,8 +10,8 @@ const MASTER_KEY = 'a'.repeat(64); const VAPID = { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }; async function seed(adapter, { uuid, recurrenceType, nextSendAt }) { - const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); - const enc = encryptForStorage(JSON.stringify({ + const userKey = await deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = await encryptForStorage(JSON.stringify({ contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', diff --git a/packages/rei-standard-amsg/server/test/sdk.test.mjs b/packages/rei-standard-amsg/server/test/sdk.test.mjs index 415b24a..79ca60e 100644 --- a/packages/rei-standard-amsg/server/test/sdk.test.mjs +++ b/packages/rei-standard-amsg/server/test/sdk.test.mjs @@ -178,25 +178,25 @@ describe('encryption utilities', () => { const masterKey = 'a'.repeat(64); const userId = 'test-user'; - it('deriveUserEncryptionKey returns a 64-char hex string', () => { - const key = deriveUserEncryptionKey(userId, masterKey); + it('deriveUserEncryptionKey returns a 64-char hex string', async () => { + const key = await deriveUserEncryptionKey(userId, masterKey); assert.equal(key.length, 64); assert.match(key, /^[0-9a-f]{64}$/); }); - it('encryptForStorage / decryptFromStorage round-trips', () => { - const key = deriveUserEncryptionKey(userId, masterKey); + it('encryptForStorage / decryptFromStorage round-trips', async () => { + const key = await deriveUserEncryptionKey(userId, masterKey); const original = JSON.stringify({ hello: 'world', num: 42 }); - const encrypted = encryptForStorage(original, key); - const decrypted = decryptFromStorage(encrypted, key); + const encrypted = await encryptForStorage(original, key); + const decrypted = await decryptFromStorage(encrypted, key); assert.equal(decrypted, original); }); - it('decryptFromStorage fails with wrong key', () => { - const key = deriveUserEncryptionKey(userId, masterKey); - const wrongKey = deriveUserEncryptionKey('other-user', masterKey); - const encrypted = encryptForStorage('secret', key); - assert.throws(() => decryptFromStorage(encrypted, wrongKey)); + it('decryptFromStorage fails with wrong key', async () => { + const key = await deriveUserEncryptionKey(userId, masterKey); + const wrongKey = await deriveUserEncryptionKey('other-user', masterKey); + const encrypted = await encryptForStorage('secret', key); + await assert.rejects(async () => await decryptFromStorage(encrypted, wrongKey)); }); }); @@ -393,7 +393,7 @@ describe('createReiServer v2.0.1 flow', () => { 'x-user-id': TEST_USER_ID }); - const encryptedBody = encryptPayload( + const encryptedBody = await encryptPayload( { contactName: 'Alice', messageType: 'fixed', @@ -517,7 +517,7 @@ describe('update-message splitPattern round-trip', () => { // Step 1: schedule a fixed message WITH a splitPattern set up front. const taskUuid = '11111111-2222-4333-8444-555555555555'; const firstSendTime = new Date(Date.now() + 60_000).toISOString(); - const scheduleBody = encryptPayload( + const scheduleBody = await encryptPayload( { uuid: taskUuid, contactName: 'Alice', @@ -544,11 +544,11 @@ describe('update-message splitPattern round-trip', () => { // Read the encrypted row back via the adapter (decrypt with userKey). const taskRow = await adapter.getTaskByUuid(taskUuid, TEST_USER_ID); assert.ok(taskRow, 'task row should exist after schedule'); - const initial = JSON.parse(decryptFromStorage(taskRow.encrypted_payload, userKey)); + const initial = JSON.parse(await decryptFromStorage(taskRow.encrypted_payload, userKey)); assert.equal(initial.splitPattern, '([\\n]+)', 'schedule persists splitPattern'); // Step 2: PUT splitPattern to a NEW value. - const updateBody1 = encryptPayload({ splitPattern: ['(\\n\\n+)', '([。!?!?]+)'] }, userKey); + const updateBody1 = await encryptPayload({ splitPattern: ['(\\n\\n+)', '([。!?!?]+)'] }, userKey); const updateResult1 = await server.handlers.updateMessage.PUT( `/api/v1/update-message?id=${taskUuid}`, { @@ -563,13 +563,13 @@ describe('update-message splitPattern round-trip', () => { assert.deepEqual(updateResult1.body.data.updatedFields, ['splitPattern']); const afterSet = await adapter.getTaskByUuid(taskUuid, TEST_USER_ID); - const afterSetData = JSON.parse(decryptFromStorage(afterSet.encrypted_payload, userKey)); + const afterSetData = JSON.parse(await decryptFromStorage(afterSet.encrypted_payload, userKey)); assert.deepEqual(afterSetData.splitPattern, ['(\\n\\n+)', '([。!?!?]+)']); // Step 3: PUT splitPattern: null → MUST reset to default (the // hasOwnProperty merge is the whole point — a truthy-spread would // silently drop null). - const updateBody2 = encryptPayload({ splitPattern: null }, userKey); + const updateBody2 = await encryptPayload({ splitPattern: null }, userKey); const updateResult2 = await server.handlers.updateMessage.PUT( `/api/v1/update-message?id=${taskUuid}`, { @@ -583,13 +583,13 @@ describe('update-message splitPattern round-trip', () => { assert.equal(updateResult2.status, 200); const afterReset = await adapter.getTaskByUuid(taskUuid, TEST_USER_ID); - const afterResetData = JSON.parse(decryptFromStorage(afterReset.encrypted_payload, userKey)); + const afterResetData = JSON.parse(await decryptFromStorage(afterReset.encrypted_payload, userKey)); assert.equal(afterResetData.splitPattern, null, 'explicit null resets the field'); // Step 4: PUT with splitPattern omitted entirely → existing value // preserved. First put it back to a concrete value, then patch some // other field and verify splitPattern survives. - const updateBody3 = encryptPayload({ splitPattern: '(##+)' }, userKey); + const updateBody3 = await encryptPayload({ splitPattern: '(##+)' }, userKey); await server.handlers.updateMessage.PUT( `/api/v1/update-message?id=${taskUuid}`, { @@ -601,7 +601,7 @@ describe('update-message splitPattern round-trip', () => { updateBody3 ); - const unrelatedPatch = encryptPayload({ avatarUrl: 'https://example.com/a.png' }, userKey); + const unrelatedPatch = await encryptPayload({ avatarUrl: 'https://example.com/a.png' }, userKey); await server.handlers.updateMessage.PUT( `/api/v1/update-message?id=${taskUuid}`, { @@ -614,7 +614,7 @@ describe('update-message splitPattern round-trip', () => { ); const preserved = await adapter.getTaskByUuid(taskUuid, TEST_USER_ID); - const preservedData = JSON.parse(decryptFromStorage(preserved.encrypted_payload, userKey)); + const preservedData = JSON.parse(await decryptFromStorage(preserved.encrypted_payload, userKey)); assert.equal(preservedData.splitPattern, '(##+)', 'omitted splitPattern preserves prior value'); assert.equal(preservedData.avatarUrl, 'https://example.com/a.png'); }); @@ -626,7 +626,7 @@ describe('update-message splitPattern round-trip', () => { // Need an existing task to update. const taskUuid = '22222222-2222-4333-8444-666666666666'; - const scheduleBody = encryptPayload( + const scheduleBody = await encryptPayload( { uuid: taskUuid, contactName: 'Bob', @@ -647,7 +647,7 @@ describe('update-message splitPattern round-trip', () => { scheduleBody ); - const badBody = encryptPayload({ splitPattern: '[' }, userKey); + const badBody = await encryptPayload({ splitPattern: '[' }, userKey); const result = await server.handlers.updateMessage.PUT( `/api/v1/update-message?id=${taskUuid}`, { @@ -670,7 +670,7 @@ describe('update-message splitPattern round-trip', () => { const { tenantToken, userKey } = await bootstrapTenant(server); const taskUuid = '33333333-2222-4333-8444-777777777777'; - const scheduleBody = encryptPayload( + const scheduleBody = await encryptPayload( { uuid: taskUuid, contactName: 'Carol', @@ -694,7 +694,7 @@ describe('update-message splitPattern round-trip', () => { // Update with a bad avatarUrl AND a valid userMessage change. The bad // avatar is silently dropped; the other field still gets written. - const patchBody = encryptPayload( + const patchBody = await encryptPayload( { avatarUrl: 'data:image/png;base64,xxx', userMessage: 'updated' }, userKey ); @@ -711,7 +711,7 @@ describe('update-message splitPattern round-trip', () => { assert.equal(result.status, 200); const after = await adapter.getTaskByUuid(taskUuid, TEST_USER_ID); - const afterData = JSON.parse(decryptFromStorage(after.encrypted_payload, userKey)); + const afterData = JSON.parse(await decryptFromStorage(after.encrypted_payload, userKey)); assert.equal(afterData.avatarUrl, 'https://example.com/original.png', 'bad avatar stripped → original preserved'); assert.equal(afterData.userMessage, 'updated', 'sibling field still applied'); }); diff --git a/packages/rei-standard-amsg/server/test/single-user-server.test.mjs b/packages/rei-standard-amsg/server/test/single-user-server.test.mjs index 9b608cc..15fdc52 100644 --- a/packages/rei-standard-amsg/server/test/single-user-server.test.mjs +++ b/packages/rei-standard-amsg/server/test/single-user-server.test.mjs @@ -15,9 +15,9 @@ async function makeServer() { return server; } -function encBody(obj) { - const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); - return JSON.stringify(encryptPayload(obj, userKey)); +async function encBody(obj) { + const userKey = await deriveUserEncryptionKey(USER, MASTER_KEY); + return JSON.stringify(await encryptPayload(obj, userKey)); } test('createSingleUserServer exposes the reused handlers + init', async () => { @@ -44,7 +44,7 @@ test('schedule → list → cancel round-trips through single-user server over D recurrenceType: 'none', pushSubscription: { endpoint: 'https://example.com/x', keys: { p256dh: 'k', auth: 'a' } } }; - const created = await server.handlers.scheduleMessage.POST(headers, encBody(payload)); + const created = await server.handlers.scheduleMessage.POST(headers, await encBody(payload)); assert.equal(created.status, 201); const uuid = created.body.data.uuid; @@ -55,8 +55,8 @@ test('schedule → list → cancel round-trips through single-user server over D assert.equal(cancelled.status, 200); }); -test('masterKey wiring: storage encrypt/decrypt round-trips', () => { - const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); - const round = JSON.parse(decryptFromStorage(encryptForStorage(JSON.stringify({ a: 1 }), userKey), userKey)); +test('masterKey wiring: storage encrypt/decrypt round-trips', async () => { + const userKey = await deriveUserEncryptionKey(USER, MASTER_KEY); + const round = JSON.parse(await decryptFromStorage(await encryptForStorage(JSON.stringify({ a: 1 }), userKey), userKey)); assert.equal(round.a, 1); }); diff --git a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs index 3e610ea..b4237c7 100644 --- a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs +++ b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs @@ -27,8 +27,8 @@ test('fetch routes init + schedule + messages, unknown → 404', async () => { const initRes = await worker.fetch(new Request('https://w.dev/init-tenant', { method: 'POST' }), env); assert.equal(initRes.status, 200); - const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); - const body = JSON.stringify(encryptPayload({ + const userKey = await deriveUserEncryptionKey(USER, MASTER_KEY); + const body = JSON.stringify(await encryptPayload({ contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', firstSendTime: '2999-01-01T00:00:00.000Z', recurrenceType: 'none', pushSubscription: { endpoint: 'https://e.com/x', keys: { p256dh: 'k', auth: 'a' } } @@ -58,8 +58,8 @@ test('scheduled() runs the tick over env.DB', async () => { const d1 = createTestD1(); const adapter = createD1Adapter(d1); await adapter.initSchema(); - const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); - const enc = encryptForStorage(JSON.stringify({ + const userKey = await deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = await encryptForStorage(JSON.stringify({ contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', recurrenceType: 'none', pushSubscription: { endpoint: 'https://e.com/x', keys: { p256dh: 'k', auth: 'a' } } }), userKey); @@ -251,8 +251,8 @@ test('the exposed VAPID public key is the same key push signing actually uses', const d1 = createTestD1(); const adapter = createD1Adapter(d1); await adapter.initSchema(); - const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); - const enc = encryptForStorage(JSON.stringify({ + const userKey = await deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = await encryptForStorage(JSON.stringify({ contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', recurrenceType: 'none', pushSubscription: sub }), userKey);