Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .changeset/amsg-server-cloudflare-webcrypto.md
Original file line number Diff line number Diff line change
@@ -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`。
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).
* 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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
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 @@ -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' } } };
Expand All @@ -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,
Expand Down Expand Up @@ -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
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
110 changes: 59 additions & 51 deletions packages/rei-standard-amsg/server/src/server/lib/encryption.js
Original file line number Diff line number Diff line change
@@ -1,70 +1,80 @@
/**
* 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']);
}
Comment on lines +31 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The importAesKey function does not validate that hexKey is a valid 64-character hex string (representing a 256-bit AES key). If an invalid key length is passed, subtle.importKey will throw a generic DOMException. Adding a validation check here improves error clarity and robustness.

async function importAesKey(hexKey) {
  if (typeof hexKey !== 'string' || hexKey.length !== 64) {
    throw new Error('Invalid encryption key: must be a 64-character hex string');
  }
  return subtle.importKey('raw', hexToBytes(hexKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}


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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The decryptPayload function does not validate that encryptedPayload is a non-null object or that it contains the required iv, authTag, and encryptedData properties. If any of these are missing or if the payload is null/undefined, it will throw a cryptic TypeError. Adding defensive checks here ensures robust error handling.

export async function decryptPayload(encryptedPayload, encryptionKey) {
  if (!encryptedPayload || typeof encryptedPayload !== 'object') {
    throw new Error('Invalid encrypted payload: must be an object');
  }
  const { iv, authTag, encryptedData } = encryptedPayload;
  if (!iv || !authTag || !encryptedData) {
    throw new Error('Invalid encrypted payload: missing iv, authTag, or encryptedData');
  }
  const key = await importAesKey(encryptionKey);
  const sealed = concatBytes(base64ToBytes(encryptedData), base64ToBytes(authTag));
  const plain = await subtle.decrypt({ name: 'AES-GCM', iv: base64ToBytes(iv), tagLength: 128 }, key, sealed);
  return JSON.parse(utf8Decode(plain));
}


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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The decryptFromStorage function does not validate that encryptedText is a valid string or that it contains exactly three colon-separated parts (iv:authTag:encryptedData). If the input is malformed or missing parts, it will cause downstream helper functions like hexToBytes to throw cryptic TypeErrors. Adding defensive checks here ensures robust error handling.

Suggested change
export async function decryptFromStorage(encryptedText, encryptionKey) {
const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
const decipher = createDecipheriv(
'aes-256-gcm',
Buffer.from(encryptionKey, 'hex'),
Buffer.from(ivHex, 'hex')
);
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8');
const key = await importAesKey(encryptionKey);
const sealed = concatBytes(hexToBytes(encryptedDataHex), hexToBytes(authTagHex));
const plain = await subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(ivHex), tagLength: 128 }, key, sealed);
return utf8Decode(plain);
}
export async function decryptFromStorage(encryptedText, encryptionKey) {
if (typeof encryptedText !== 'string') {
throw new Error('Invalid encrypted text: must be a string');
}
const parts = encryptedText.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted text format: expected iv:authTag:encryptedData');
}
const [ivHex, authTagHex, encryptedDataHex] = parts;
const key = await importAesKey(encryptionKey);
const sealed = concatBytes(hexToBytes(encryptedDataHex), hexToBytes(authTagHex));
const plain = await subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(ivHex), tagLength: 128 }, key, sealed);
return utf8Decode(plain);
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +80 to +86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The hexToBytes function does not validate that the input hex is a valid even-length hex string. If an odd-length string is passed, hex.length / 2 results in a float, which is truncated to an integer when creating the Uint8Array, silently ignoring the last character. For cryptographic operations, any malformed or odd-length hex string should be rejected immediately to prevent silent data truncation or corruption.

export function hexToBytes(hex) {
  if (typeof hex !== 'string' || hex.length % 2 !== 0) {
    throw new Error('Invalid hex string: must be an even-length string');
  }
  const out = new Uint8Array(hex.length / 2);
  for (let i = 0; i < out.length; i++) {
    const val = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(val)) {
      throw new Error('Invalid hex character');
    }
    out[i] = val;
  }
  return out;
}


/**
* Constant-time byte comparison. Returns true iff `a` and `b` are equal-length
* sequences with the same bytes. Length is intentionally NOT secret — early
Expand Down
Loading
Loading