Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/amsg-server-webcrypto-encryption.md
Original file line number Diff line number Diff line change
@@ -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)行为不变。
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name = "amsg-single-user"
main = "worker.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
Expand Down
5 changes: 3 additions & 2 deletions packages/rei-standard-amsg/server/src/server/cloudflare.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function createGetUserKeyHandler(ctx) {
body: {
success: true,
data: {
userKey: deriveUserEncryptionKey(userId, masterKey),
userKey: await deriveUserEncryptionKey(userId, masterKey),
version: 1
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' } } };
Expand All @@ -76,7 +76,6 @@ export function createScheduleMessageHandler(ctx) {
}

const taskUuid = payload.uuid || randomUUID();
const userKey = deriveUserEncryptionKey(userId, masterKey);

const fullTaskData = {
contactName: payload.contactName,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '请求体解密失败' } } };
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
143 changes: 97 additions & 46 deletions packages/rei-standard-amsg/server/src/server/lib/encryption.js
Original file line number Diff line number Diff line change
@@ -1,70 +1,123 @@
/**
* 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)
};
Comment on lines +53 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Performance Optimization: Zero-Copy Subarray

Using sealed.slice copies the underlying ArrayBuffer twice to create new Uint8Array instances. Since the returned ciphertext and authTag are immediately encoded to base64/hex and then discarded, there is no risk of accidental mutation.

Using sealed.subarray creates a view over the existing buffer without copying the bytes, making it a zero-copy, highly efficient alternative.

Suggested change
return {
ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES),
authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES)
};
return {
ciphertext: sealed.subarray(0, sealed.length - TAG_LENGTH_BYTES),
authTag: sealed.subarray(sealed.length - TAG_LENGTH_BYTES)
};

}

/** 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<string>} 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);
}

/**
* Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded).
*
* @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
* @param {string} encryptionKey - 64-char hex key.
* @returns {Object} Decrypted JSON object.
* @returns {Promise<Object>} 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));
}

/**
* Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded).
*
* @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)
};
}

Expand All @@ -73,30 +126,28 @@ 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<string>} 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)}`;
}

/**
* Decrypt data from database storage format.
*
* @param {string} encryptedText - Format: iv:authTag:encryptedData
* @param {string} encryptionKey - 64-char hex key.
* @returns {string} Plaintext string.
* @returns {Promise<string>} 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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} */
Expand Down
4 changes: 2 additions & 2 deletions packages/rei-standard-amsg/server/src/server/lib/run-tick.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading