diff --git a/.changeset/amsg-server-webcrypto-encryption.md b/.changeset/amsg-server-webcrypto-encryption.md new file mode 100644 index 0000000..6f1ea85 --- /dev/null +++ b/.changeset/amsg-server-webcrypto-encryption.md @@ -0,0 +1,10 @@ +--- +"@rei-standard/amsg-server": minor +--- + +`/cloudflare` 路径去掉 node `crypto` 依赖,载荷加密改用 Web Crypto。单用户 Worker 现在可以用 esbuild `--platform=neutral` 打成自包含单文件直接粘进 Cloudflare Dashboard,不再需要 `nodejs_compat` 兼容开关(示例 wrangler.toml 已同步去掉该 flag)。 + +- `lib/encryption.js` 的 5 个导出(`deriveUserEncryptionKey` / `encryptPayload` / `decryptPayload` / `encryptForStorage` / `decryptFromStorage`)改为基于 `globalThis.crypto.subtle` 实现。线格式与旧实现逐字节兼容——老数据、老客户端不受影响,并有跨实现互通测试钉住。 +- 迁移注意:这 5 个函数全部从同步改为 async,直接 import 它们的调用方需要补 `await`。 +- `randomUUID` 改从 runtime-neutral 的 webcrypto helper 获取,不再 import node `crypto`。 +- 多租户入口(Netlify/Neon)行为不变。 diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml index ee8d5ff..8ad4823 100644 --- a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml @@ -1,7 +1,6 @@ name = "amsg-single-user" main = "worker.js" compatibility_date = "2024-09-23" -compatibility_flags = ["nodejs_compat"] [[d1_databases]] binding = "DB" diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare.js b/packages/rei-standard-amsg/server/src/server/cloudflare.js index 5d694e2..121de94 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). + * This subgraph uses no Node builtins — encryption and Web Push signing both + * run on `globalThis.crypto` (Web Crypto) — so the bundle needs no + * `nodejs_compat` compatibility flag. */ 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..ca09018 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 @@ -40,7 +40,7 @@ export function createGetUserKeyHandler(ctx) { body: { success: true, data: { - userKey: deriveUserEncryptionKey(userId, masterKey), + userKey: await deriveUserEncryptionKey(userId, masterKey), 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..8221213 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'; @@ -47,11 +47,11 @@ export function createScheduleMessageHandler(ctx) { } const encryptedBody = parsedBody.data; + const userKey = await deriveUserEncryptionKey(userId, masterKey); let payload; try { - const userKey = deriveUserEncryptionKey(userId, masterKey); - payload = decryptPayload(encryptedBody, userKey); + 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,6 @@ export function createScheduleMessageHandler(ctx) { } const taskUuid = payload.uuid || randomUUID(); - const userKey = deriveUserEncryptionKey(userId, masterKey); const fullTaskData = { contactName: payload.contactName, @@ -104,7 +103,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..19ccbaf 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,87 @@ /** * Encryption utility library (SDK version) - * ReiStandard SDK v2.0.1 * * Wraps AES-256-GCM operations for request/response and storage encryption. + * Implemented on `globalThis.crypto.subtle` (Web Crypto) so the module runs + * on Cloudflare Workers and other edge runtimes without `nodejs_compat`, as + * well as on Node ≥ 19. All exports are async because SubtleCrypto is. + * + * Wire formats are byte-identical to the previous node:crypto implementation + * (pinned by test/encryption-compat.test.mjs): + * - payload: 12-byte IV, base64 `{ iv, authTag, encryptedData }` + * - storage: 16-byte IV, hex `iv:authTag:encryptedData` + * Web Crypto appends the GCM auth tag to the ciphertext, while these formats + * carry it detached — aesGcmSeal / aesGcmOpen below own that seam. */ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'; +import { + utf8, + utf8Decode, + randomBytes, + concatBytes, + bytesToBase64, + base64UrlToBytes, + bytesToHex, + hexToBytes +} from './webcrypto-utils.js'; + +// Decoder for the wire formats' base64 fields. base64UrlToBytes normalizes +// the url-safe alphabet and padding first, so it accepts both standard and +// url-safe base64 — same leniency as node's Buffer.from(x, 'base64'). +const base64ToBytes = base64UrlToBytes; + +const TAG_LENGTH_BYTES = 16; // AES-GCM auth tag emitted on encrypt + +function importAesKey(hexKey, usage) { + return globalThis.crypto.subtle.importKey( + 'raw', + hexToBytes(hexKey), + { name: 'AES-GCM' }, + false, + [usage] + ); +} + +/** AES-256-GCM encrypt; returns the ciphertext and the detached 16-byte tag. */ +async function aesGcmSeal(hexKey, iv, plaintextBytes) { + const key = await importAesKey(hexKey, 'encrypt'); + const sealed = new Uint8Array(await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength: TAG_LENGTH_BYTES * 8 }, + key, + plaintextBytes + )); + return { + ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES), + authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES) + }; +} + +/** AES-256-GCM decrypt from detached-tag form; returns plaintext bytes. */ +async function aesGcmOpen(hexKey, iv, ciphertext, authTag) { + const key = await importAesKey(hexKey, 'decrypt'); + // node's decipher accepted GCM tags truncated down to 96 bits; mirror that + // by sizing tagLength from the provided tag. Anything outside the standard + // 96–128-bit range keeps 128 and fails authentication, as it did before. + const tagBits = authTag.length * 8; + const tagLength = tagBits >= 96 && tagBits <= 128 ? tagBits : 128; + const plain = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv, tagLength }, + key, + concatBytes(ciphertext, authTag) + ); + return new Uint8Array(plain); +} /** * 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 globalThis.crypto.subtle.digest('SHA-256', utf8(masterKey + userId)); + return bytesToHex(new Uint8Array(digest)).slice(0, 64); } /** @@ -26,25 +89,17 @@ 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') + const plain = await aesGcmOpen( + encryptionKey, + base64ToBytes(iv), + base64ToBytes(encryptedData), + base64ToBytes(authTag) ); - - decipher.setAuthTag(Buffer.from(authTag, 'base64')); - - const decrypted = Buffer.concat([ - decipher.update(Buffer.from(encryptedData, 'base64')), - decipher.final() - ]); - - return JSON.parse(decrypted.toString('utf8')); + return JSON.parse(utf8Decode(plain)); } /** @@ -52,19 +107,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 { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8(plaintext)); return { - iv: iv.toString('base64'), - authTag: authTag.toString('base64'), - encryptedData: encrypted.toString('base64') + iv: bytesToBase64(iv), + authTag: bytesToBase64(authTag), + encryptedData: bytesToBase64(ciphertext) }; } @@ -73,14 +126,12 @@ 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 { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8(text)); + return `${bytesToHex(iv)}:${bytesToHex(authTag)}:${bytesToHex(ciphertext)}`; } /** @@ -88,15 +139,15 @@ 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') + const plain = await aesGcmOpen( + encryptionKey, + hexToBytes(ivHex), + hexToBytes(encryptedDataHex), + hexToBytes(authTagHex) ); - decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); - return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8'); + 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..a0485ba 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 @@ -26,18 +26,41 @@ export function utf8Decode(buf) { } -/** Encode bytes as base64url (no padding). */ -export function bytesToBase64Url(buf) { +/** 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]); } // 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, ''); +} + +/** Encode bytes as base64url (no padding). */ +export function bytesToBase64Url(buf) { + return bytesToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +/** Encode bytes as lowercase hex. */ +export function bytesToHex(buf) { + const bytes = toUint8(buf); + let out = ''; + for (let i = 0; i < bytes.length; i++) { + out += bytes[i].toString(16).padStart(2, '0'); + } + return out; +} + +/** 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; } diff --git a/packages/rei-standard-amsg/server/test/encryption-compat.test.mjs b/packages/rei-standard-amsg/server/test/encryption-compat.test.mjs new file mode 100644 index 0000000..adceec3 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/encryption-compat.test.mjs @@ -0,0 +1,209 @@ +/** + * Cross-implementation compatibility tests for lib/encryption.js. + * + * The library's wire formats are consumed by already-deployed clients and + * already-written database rows, so they must stay byte-compatible across + * implementation changes. These tests pin the formats against an independent + * node:crypto reference implementation: + * + * - payload format: AES-256-GCM, 12-byte IV, base64 fields + * { iv, authTag, encryptedData } with a detached 16-byte tag + * - storage format: AES-256-GCM, 16-byte IV, hex `iv:authTag:data` + * - key derivation: sha256(masterKey + userId) hex, first 64 chars + * + * If any of IV length / tag handling / encoding / derivation changes, the + * cross-decryption tests here fail. All calls `await` the library functions so + * the suite passes whether the exports are sync or async. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +import { + deriveUserEncryptionKey, + encryptPayload, + decryptPayload, + encryptForStorage, + decryptFromStorage +} from '../src/server/lib/encryption.js'; + +const MASTER_KEY = 'a'.repeat(64); +const USER_ID = '123e4567-e89b-42d3-a456-426614174000'; + +/** node:crypto reference: encrypt in the payload wire format (12-byte IV, base64). */ +function referenceEncryptPayload(payload, hexKey) { + const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', Buffer.from(hexKey, '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') + }; +} + +/** node:crypto reference: decrypt the payload wire format. */ +function referenceDecryptPayload({ iv, authTag, encryptedData }, hexKey) { + const decipher = createDecipheriv('aes-256-gcm', Buffer.from(hexKey, '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')); +} + +/** node:crypto reference: encrypt in the storage wire format (16-byte IV, hex `iv:tag:data`). */ +function referenceEncryptForStorage(text, hexKey) { + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-gcm', Buffer.from(hexKey, 'hex'), iv); + const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex'); + return `${iv.toString('hex')}:${cipher.getAuthTag().toString('hex')}:${encrypted}`; +} + +/** node:crypto reference: decrypt the storage wire format. */ +function referenceDecryptFromStorage(encryptedText, hexKey) { + const [ivHex, tagHex, dataHex] = encryptedText.split(':'); + const decipher = createDecipheriv('aes-256-gcm', Buffer.from(hexKey, 'hex'), Buffer.from(ivHex, 'hex')); + decipher.setAuthTag(Buffer.from(tagHex, 'hex')); + return decipher.update(dataHex, 'hex', 'utf8') + decipher.final('utf8'); +} + +describe('deriveUserEncryptionKey', () => { + it('matches sha256(masterKey + userId) hex truncated to 64 chars', async () => { + const expected = createHash('sha256').update(MASTER_KEY + USER_ID).digest('hex').slice(0, 64); + assert.strictEqual(await deriveUserEncryptionKey(USER_ID, MASTER_KEY), expected); + }); +}); + +describe('payload format cross-compatibility', () => { + it('decryptPayload reads node:crypto-produced ciphertext', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const original = { contactName: '小 明', note: 'emoji 🎉 と日本語', n: 42 }; + const sealed = referenceEncryptPayload(original, key); + assert.deepStrictEqual(await decryptPayload(sealed, key), original); + }); + + it('node:crypto reads encryptPayload-produced ciphertext', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const original = { tasks: [{ uuid: 'x', text: '中文句子。' }], ok: true }; + const sealed = await encryptPayload(original, key); + assert.deepStrictEqual(referenceDecryptPayload(sealed, key), original); + }); + + it('keeps the 12-byte IV and detached 16-byte authTag', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const sealed = await encryptPayload({ a: 1 }, key); + assert.strictEqual(Buffer.from(sealed.iv, 'base64').length, 12); + assert.strictEqual(Buffer.from(sealed.authTag, 'base64').length, 16); + }); + + it('accepts a pre-serialized JSON string payload', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const sealed = await encryptPayload(JSON.stringify({ s: 'str' }), key); + assert.deepStrictEqual(referenceDecryptPayload(sealed, key), { s: 'str' }); + }); +}); + +describe('storage format cross-compatibility', () => { + it('decryptFromStorage reads node:crypto-produced ciphertext', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const original = JSON.stringify({ userMessage: '你好!🌸', recurrenceType: 'daily' }); + const stored = referenceEncryptForStorage(original, key); + assert.strictEqual(await decryptFromStorage(stored, key), original); + }); + + it('node:crypto reads encryptForStorage-produced ciphertext', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const original = '混合 content:emoji 🚀、換行\n、空格 '; + const stored = await encryptForStorage(original, key); + assert.strictEqual(referenceDecryptFromStorage(stored, key), original); + }); + + it('keeps the hex iv:authTag:data layout with a 16-byte IV', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const stored = await encryptForStorage('abc', key); + const [ivHex, tagHex, dataHex] = stored.split(':'); + assert.match(ivHex, /^[0-9a-f]{32}$/); + assert.match(tagHex, /^[0-9a-f]{32}$/); + assert.match(dataHex, /^[0-9a-f]*$/); + }); + + it('round-trips the empty string', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const stored = await encryptForStorage('', key); + assert.strictEqual(await decryptFromStorage(stored, key), ''); + }); +}); + +describe('lenient decode compatibility (node Buffer.from parity)', () => { + it('decryptPayload accepts base64url-encoded fields, like Buffer.from(x, "base64") did', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const original = { compat: 'base64url ✓' }; + const sealed = referenceEncryptPayload(original, key); + const toUrlSafe = (b64) => b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + const urlSafe = { + iv: toUrlSafe(sealed.iv), + authTag: toUrlSafe(sealed.authTag), + encryptedData: toUrlSafe(sealed.encryptedData) + }; + assert.deepStrictEqual(await decryptPayload(urlSafe, key), original); + }); + + it('accepts GCM auth tags truncated to 96 bits, like node decipher did', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const original = { compat: 'truncated tag' }; + const sealed = referenceEncryptPayload(original, key); + const tag12 = Buffer.from(sealed.authTag, 'base64').subarray(0, 12).toString('base64'); + assert.deepStrictEqual(await decryptPayload({ ...sealed, authTag: tag12 }, key), original); + + const stored = referenceEncryptForStorage('truncated tag storage', key); + const [ivHex, tagHex, dataHex] = stored.split(':'); + assert.strictEqual( + await decryptFromStorage(`${ivHex}:${tagHex.slice(0, 24)}:${dataHex}`, key), + 'truncated tag storage' + ); + }); + + it('rejects tags shorter than 96 bits', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const sealed = referenceEncryptPayload({ a: 1 }, key); + const tag8 = Buffer.from(sealed.authTag, 'base64').subarray(0, 8).toString('base64'); + await assert.rejects(async () => decryptPayload({ ...sealed, authTag: tag8 }, key)); + }); +}); + +describe('tamper detection', () => { + it('decryptPayload rejects a flipped ciphertext byte', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const sealed = await encryptPayload({ secret: 'data' }, key); + const bytes = Buffer.from(sealed.encryptedData, 'base64'); + bytes[0] ^= 0xff; + const tampered = { ...sealed, encryptedData: bytes.toString('base64') }; + await assert.rejects(async () => decryptPayload(tampered, key)); + }); + + it('decryptPayload rejects a flipped authTag byte', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const sealed = await encryptPayload({ secret: 'data' }, key); + const tag = Buffer.from(sealed.authTag, 'base64'); + tag[0] ^= 0xff; + const tampered = { ...sealed, authTag: tag.toString('base64') }; + await assert.rejects(async () => decryptPayload(tampered, key)); + }); + + it('decryptFromStorage rejects a flipped ciphertext byte', async () => { + const key = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const stored = await encryptForStorage('secret data', key); + const [ivHex, tagHex, dataHex] = stored.split(':'); + const flipped = (parseInt(dataHex.slice(0, 2), 16) ^ 0xff).toString(16).padStart(2, '0'); + const tampered = `${ivHex}:${tagHex}:${flipped}${dataHex.slice(2)}`; + await assert.rejects(async () => decryptFromStorage(tampered, key)); + }); + + it('decryptPayload rejects ciphertext sealed with a different key', async () => { + const keyA = await deriveUserEncryptionKey(USER_ID, MASTER_KEY); + const keyB = await deriveUserEncryptionKey(USER_ID, 'b'.repeat(64)); + const sealed = await encryptPayload({ secret: 'data' }, keyA); + await assert.rejects(async () => decryptPayload(sealed, keyB)); + }); +}); 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..a055350 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(() => 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);