@rei-standard/amsg-server 2.6.0-next.1 → 2.6.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,4 @@
1
- import { createDecipheriv, createHash, randomBytes as randomBytes$1, createCipheriv, randomUUID } from 'crypto';
2
- import { validateAvatarUrl, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, toUint8, base64UrlToBytes, normalizeVapidSubject, concatBytes } from '@rei-standard/amsg-shared';
1
+ import { validateAvatarUrl, toUint8, concatBytes, base64UrlToBytes, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, normalizeVapidSubject } from '@rei-standard/amsg-shared';
3
2
 
4
3
  /**
5
4
  * Validation utility library (SDK version)
@@ -407,26 +406,160 @@ function getHeader(headers = {}, name) {
407
406
  return '';
408
407
  }
409
408
 
409
+ /**
410
+ * Runtime-neutral crypto + encoding helpers.
411
+ *
412
+ * Everything in here is implemented on top of WHATWG-standard primitives
413
+ * (`globalThis.crypto.subtle`, `TextEncoder`, `Uint8Array`) so the package
414
+ * runs on Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and
415
+ * Node ≥ 19 with zero polyfills. The Node adapter polyfills
416
+ * `globalThis.crypto` for Node 18 deployments before any of these are
417
+ * touched.
418
+ */
419
+
420
+ const TEXT_ENCODER = new TextEncoder();
421
+ const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false });
422
+
423
+ /** UTF-8 encode a string into a Uint8Array. */
424
+ function utf8(str) {
425
+ return TEXT_ENCODER.encode(String(str));
426
+ }
427
+
428
+ /** UTF-8 decode a Uint8Array / ArrayBuffer into a string. */
429
+ function utf8Decode(buf) {
430
+ return TEXT_DECODER.decode(toUint8(buf));
431
+ }
432
+
433
+
434
+ /** Encode bytes as standard base64 (with padding). */
435
+ function bytesToBase64(buf) {
436
+ const bytes = toUint8(buf);
437
+ let bin = '';
438
+ for (let i = 0; i < bytes.length; i++) {
439
+ bin += String.fromCharCode(bytes[i]);
440
+ }
441
+ // btoa is available in all Web Crypto runtimes (browsers, Workers, Node 16+).
442
+ return (typeof btoa === 'function')
443
+ ? btoa(bin)
444
+ : Buffer.from(bin, 'binary').toString('base64');
445
+ }
446
+
447
+ /** Encode bytes as base64url (no padding). */
448
+ function bytesToBase64Url(buf) {
449
+ return bytesToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
450
+ }
451
+
452
+ /** Encode bytes as lowercase hex. */
453
+ function bytesToHex(buf) {
454
+ const bytes = toUint8(buf);
455
+ let out = '';
456
+ for (let i = 0; i < bytes.length; i++) {
457
+ out += bytes[i].toString(16).padStart(2, '0');
458
+ }
459
+ return out;
460
+ }
461
+
462
+ /** Decode a hex string into a Uint8Array. */
463
+ function hexToBytes(hex) {
464
+ const out = new Uint8Array(hex.length / 2);
465
+ for (let i = 0; i < out.length; i++) {
466
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
467
+ }
468
+ return out;
469
+ }
470
+
471
+
472
+ /** Encode a JSON-serializable value as base64url (UTF-8 JSON). */
473
+ function jsonToBase64Url(value) {
474
+ return bytesToBase64Url(utf8(JSON.stringify(value)));
475
+ }
476
+
477
+ /** `crypto.randomUUID()`. The Node adapter polyfills `globalThis.crypto`. */
478
+ function randomUUID() {
479
+ return globalThis.crypto.randomUUID();
480
+ }
481
+
482
+ /** Cryptographically random bytes. */
483
+ function randomBytes(n) {
484
+ const out = new Uint8Array(n);
485
+ globalThis.crypto.getRandomValues(out);
486
+ return out;
487
+ }
488
+
410
489
  /**
411
490
  * Encryption utility library (SDK version)
412
- * ReiStandard SDK v2.0.1
413
491
  *
414
492
  * Wraps AES-256-GCM operations for request/response and storage encryption.
493
+ * Implemented on `globalThis.crypto.subtle` (Web Crypto) so the module runs
494
+ * on Cloudflare Workers and other edge runtimes without `nodejs_compat`, as
495
+ * well as on Node ≥ 19. All exports are async because SubtleCrypto is.
496
+ *
497
+ * Wire formats are byte-identical to the previous node:crypto implementation
498
+ * (pinned by test/encryption-compat.test.mjs):
499
+ * - payload: 12-byte IV, base64 `{ iv, authTag, encryptedData }`
500
+ * - storage: 16-byte IV, hex `iv:authTag:encryptedData`
501
+ * Web Crypto appends the GCM auth tag to the ciphertext, while these formats
502
+ * carry it detached — aesGcmSeal / aesGcmOpen below own that seam.
415
503
  */
416
504
 
417
505
 
506
+ // Decoder for the wire formats' base64 fields. base64UrlToBytes normalizes
507
+ // the url-safe alphabet and padding first, so it accepts both standard and
508
+ // url-safe base64 — same leniency as node's Buffer.from(x, 'base64').
509
+ const base64ToBytes = base64UrlToBytes;
510
+
511
+ const TAG_LENGTH_BYTES = 16; // AES-GCM auth tag emitted on encrypt
512
+
513
+ function importAesKey(hexKey, usage) {
514
+ return globalThis.crypto.subtle.importKey(
515
+ 'raw',
516
+ hexToBytes(hexKey),
517
+ { name: 'AES-GCM' },
518
+ false,
519
+ [usage]
520
+ );
521
+ }
522
+
523
+ /** AES-256-GCM encrypt; returns the ciphertext and the detached 16-byte tag. */
524
+ async function aesGcmSeal(hexKey, iv, plaintextBytes) {
525
+ const key = await importAesKey(hexKey, 'encrypt');
526
+ const sealed = new Uint8Array(await globalThis.crypto.subtle.encrypt(
527
+ { name: 'AES-GCM', iv, tagLength: TAG_LENGTH_BYTES * 8 },
528
+ key,
529
+ plaintextBytes
530
+ ));
531
+ return {
532
+ ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES),
533
+ authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES)
534
+ };
535
+ }
536
+
537
+ /** AES-256-GCM decrypt from detached-tag form; returns plaintext bytes. */
538
+ async function aesGcmOpen(hexKey, iv, ciphertext, authTag) {
539
+ const key = await importAesKey(hexKey, 'decrypt');
540
+ // node's decipher accepted GCM tags truncated down to 96 bits; mirror that
541
+ // by sizing tagLength from the provided tag. Anything outside the standard
542
+ // 96–128-bit range keeps 128 and fails authentication, as it did before.
543
+ const tagBits = authTag.length * 8;
544
+ const tagLength = tagBits >= 96 && tagBits <= 128 ? tagBits : 128;
545
+ const plain = await globalThis.crypto.subtle.decrypt(
546
+ { name: 'AES-GCM', iv, tagLength },
547
+ key,
548
+ concatBytes(ciphertext, authTag)
549
+ );
550
+ return new Uint8Array(plain);
551
+ }
552
+
418
553
  /**
419
554
  * Derive a user-specific encryption key from the master key.
420
555
  *
421
556
  * @param {string} userId - Unique user identifier.
422
557
  * @param {string} masterKey - 64-char hex master key.
423
- * @returns {string} 64-char hex key.
558
+ * @returns {Promise<string>} 64-char hex key.
424
559
  */
425
- function deriveUserEncryptionKey(userId, masterKey) {
426
- return createHash('sha256')
427
- .update(masterKey + userId)
428
- .digest('hex')
429
- .slice(0, 64);
560
+ async function deriveUserEncryptionKey(userId, masterKey) {
561
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', utf8(masterKey + userId));
562
+ return bytesToHex(new Uint8Array(digest)).slice(0, 64);
430
563
  }
431
564
 
432
565
  /**
@@ -434,25 +567,17 @@ function deriveUserEncryptionKey(userId, masterKey) {
434
567
  *
435
568
  * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
436
569
  * @param {string} encryptionKey - 64-char hex key.
437
- * @returns {Object} Decrypted JSON object.
570
+ * @returns {Promise<Object>} Decrypted JSON object.
438
571
  */
439
- function decryptPayload(encryptedPayload, encryptionKey) {
572
+ async function decryptPayload(encryptedPayload, encryptionKey) {
440
573
  const { iv, authTag, encryptedData } = encryptedPayload;
441
-
442
- const decipher = createDecipheriv(
443
- 'aes-256-gcm',
444
- Buffer.from(encryptionKey, 'hex'),
445
- Buffer.from(iv, 'base64')
574
+ const plain = await aesGcmOpen(
575
+ encryptionKey,
576
+ base64ToBytes(iv),
577
+ base64ToBytes(encryptedData),
578
+ base64ToBytes(authTag)
446
579
  );
447
-
448
- decipher.setAuthTag(Buffer.from(authTag, 'base64'));
449
-
450
- const decrypted = Buffer.concat([
451
- decipher.update(Buffer.from(encryptedData, 'base64')),
452
- decipher.final()
453
- ]);
454
-
455
- return JSON.parse(decrypted.toString('utf8'));
580
+ return JSON.parse(utf8Decode(plain));
456
581
  }
457
582
 
458
583
  /**
@@ -460,19 +585,17 @@ function decryptPayload(encryptedPayload, encryptionKey) {
460
585
  *
461
586
  * @param {string|Object} payload
462
587
  * @param {string} encryptionKey - 64-char hex key.
463
- * @returns {{ iv: string, authTag: string, encryptedData: string }}
588
+ * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>}
464
589
  */
465
- function encryptPayload(payload, encryptionKey) {
590
+ async function encryptPayload(payload, encryptionKey) {
466
591
  const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
467
- const iv = randomBytes$1(12);
468
- const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
469
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
470
- const authTag = cipher.getAuthTag();
592
+ const iv = randomBytes(12);
593
+ const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8(plaintext));
471
594
 
472
595
  return {
473
- iv: iv.toString('base64'),
474
- authTag: authTag.toString('base64'),
475
- encryptedData: encrypted.toString('base64')
596
+ iv: bytesToBase64(iv),
597
+ authTag: bytesToBase64(authTag),
598
+ encryptedData: bytesToBase64(ciphertext)
476
599
  };
477
600
  }
478
601
 
@@ -481,14 +604,12 @@ function encryptPayload(payload, encryptionKey) {
481
604
  *
482
605
  * @param {string} text - Plaintext string.
483
606
  * @param {string} encryptionKey - 64-char hex key.
484
- * @returns {string} Format: iv:authTag:encryptedData
607
+ * @returns {Promise<string>} Format: iv:authTag:encryptedData
485
608
  */
486
- function encryptForStorage(text, encryptionKey) {
487
- const iv = randomBytes$1(16);
488
- const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
489
- const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
490
- const authTag = cipher.getAuthTag();
491
- return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
609
+ async function encryptForStorage(text, encryptionKey) {
610
+ const iv = randomBytes(16);
611
+ const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8(text));
612
+ return `${bytesToHex(iv)}:${bytesToHex(authTag)}:${bytesToHex(ciphertext)}`;
492
613
  }
493
614
 
494
615
  /**
@@ -496,17 +617,17 @@ function encryptForStorage(text, encryptionKey) {
496
617
  *
497
618
  * @param {string} encryptedText - Format: iv:authTag:encryptedData
498
619
  * @param {string} encryptionKey - 64-char hex key.
499
- * @returns {string} Plaintext string.
620
+ * @returns {Promise<string>} Plaintext string.
500
621
  */
501
- function decryptFromStorage(encryptedText, encryptionKey) {
622
+ async function decryptFromStorage(encryptedText, encryptionKey) {
502
623
  const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
503
- const decipher = createDecipheriv(
504
- 'aes-256-gcm',
505
- Buffer.from(encryptionKey, 'hex'),
506
- Buffer.from(ivHex, 'hex')
624
+ const plain = await aesGcmOpen(
625
+ encryptionKey,
626
+ hexToBytes(ivHex),
627
+ hexToBytes(encryptedDataHex),
628
+ hexToBytes(authTagHex)
507
629
  );
508
- decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
509
- return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8');
630
+ return utf8Decode(plain);
510
631
  }
511
632
 
512
633
  /**
@@ -548,7 +669,7 @@ function createGetUserKeyHandler(ctx) {
548
669
  body: {
549
670
  success: true,
550
671
  data: {
551
- userKey: deriveUserEncryptionKey(userId, masterKey),
672
+ userKey: await deriveUserEncryptionKey(userId, masterKey),
552
673
  version: 1
553
674
  }
554
675
  }
@@ -675,8 +796,8 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
675
796
  return { success: false, messagesSent: 0, error: 'TENANT_MASTER_KEY_MISSING' };
676
797
  }
677
798
 
678
- const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
679
- const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
799
+ const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
800
+ const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
680
801
 
681
802
  let messageContent;
682
803
  /** @type {unknown} */
@@ -1076,11 +1197,11 @@ function createScheduleMessageHandler(ctx) {
1076
1197
  }
1077
1198
 
1078
1199
  const encryptedBody = parsedBody.data;
1200
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
1079
1201
 
1080
1202
  let payload;
1081
1203
  try {
1082
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1083
- payload = decryptPayload(encryptedBody, userKey);
1204
+ payload = await decryptPayload(encryptedBody, userKey);
1084
1205
  } catch (error) {
1085
1206
  if (error instanceof SyntaxError) {
1086
1207
  return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } };
@@ -1105,7 +1226,6 @@ function createScheduleMessageHandler(ctx) {
1105
1226
  }
1106
1227
 
1107
1228
  const taskUuid = payload.uuid || randomUUID();
1108
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1109
1229
 
1110
1230
  const fullTaskData = {
1111
1231
  contactName: payload.contactName,
@@ -1133,7 +1253,7 @@ function createScheduleMessageHandler(ctx) {
1133
1253
  metadata: payload.metadata || {}
1134
1254
  };
1135
1255
 
1136
- const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
1256
+ const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
1137
1257
 
1138
1258
  /**
1139
1259
  * In-server instant path. Delivers an instant message through this
@@ -1347,8 +1467,8 @@ async function runScheduledTick(ctx) {
1347
1467
  }
1348
1468
 
1349
1469
  try {
1350
- const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
1351
- const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
1470
+ const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
1471
+ const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1352
1472
 
1353
1473
  if (decryptedPayload.recurrenceType === 'none') {
1354
1474
  await db.deleteTaskById(task.id);
@@ -1458,11 +1578,11 @@ function createUpdateMessageHandler(ctx) {
1458
1578
  }
1459
1579
 
1460
1580
  const encryptedBody = parsedBody.data;
1461
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1581
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
1462
1582
  let updates;
1463
1583
 
1464
1584
  try {
1465
- updates = decryptPayload(encryptedBody, userKey);
1585
+ updates = await decryptPayload(encryptedBody, userKey);
1466
1586
  } catch (_error) {
1467
1587
  return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
1468
1588
  }
@@ -1538,7 +1658,7 @@ function createUpdateMessageHandler(ctx) {
1538
1658
  return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } };
1539
1659
  }
1540
1660
 
1541
- const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1661
+ const existingData = JSON.parse(await decryptFromStorage(existingTask.encrypted_payload, userKey));
1542
1662
 
1543
1663
  // When the caller switches prompt source (completePrompt ↔ messages),
1544
1664
  // null out the other so storage stays one-of (matches schedule-message
@@ -1567,7 +1687,7 @@ function createUpdateMessageHandler(ctx) {
1567
1687
  ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
1568
1688
  };
1569
1689
 
1570
- const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
1690
+ const encryptedPayload = await encryptForStorage(JSON.stringify(updatedData), userKey);
1571
1691
  const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined;
1572
1692
 
1573
1693
  const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
@@ -1694,10 +1814,10 @@ function createMessagesHandler(ctx) {
1694
1814
 
1695
1815
  const { tasks, total } = await db.listTasks(userId, { status, limit, offset });
1696
1816
 
1697
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1817
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
1698
1818
 
1699
- const decryptedTasks = tasks.map(task => {
1700
- const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
1819
+ const decryptedTasks = await Promise.all(tasks.map(async task => {
1820
+ const decrypted = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1701
1821
  return {
1702
1822
  id: task.id,
1703
1823
  uuid: task.uuid,
@@ -1711,13 +1831,13 @@ function createMessagesHandler(ctx) {
1711
1831
  createdAt: task.created_at,
1712
1832
  updatedAt: task.updated_at
1713
1833
  };
1714
- });
1834
+ }));
1715
1835
 
1716
1836
  const responsePayload = {
1717
1837
  tasks: decryptedTasks,
1718
1838
  pagination: { total, limit, offset, hasMore: offset + limit < total }
1719
1839
  };
1720
- const encryptedResponse = encryptPayload(responsePayload, userKey);
1840
+ const encryptedResponse = await encryptPayload(responsePayload, userKey);
1721
1841
 
1722
1842
  return {
1723
1843
  status: 200,
@@ -2253,53 +2373,6 @@ function createSingleUserServer(config) {
2253
2373
  };
2254
2374
  }
2255
2375
 
2256
- /**
2257
- * Runtime-neutral crypto + encoding helpers.
2258
- *
2259
- * Everything in here is implemented on top of WHATWG-standard primitives
2260
- * (`globalThis.crypto.subtle`, `TextEncoder`, `Uint8Array`) so the package
2261
- * runs on Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and
2262
- * Node ≥ 19 with zero polyfills. The Node adapter polyfills
2263
- * `globalThis.crypto` for Node 18 deployments before any of these are
2264
- * touched.
2265
- */
2266
-
2267
- const TEXT_ENCODER = new TextEncoder();
2268
- new TextDecoder('utf-8', { fatal: false });
2269
-
2270
- /** UTF-8 encode a string into a Uint8Array. */
2271
- function utf8(str) {
2272
- return TEXT_ENCODER.encode(String(str));
2273
- }
2274
-
2275
-
2276
- /** Encode bytes as base64url (no padding). */
2277
- function bytesToBase64Url(buf) {
2278
- const bytes = toUint8(buf);
2279
- let bin = '';
2280
- for (let i = 0; i < bytes.length; i++) {
2281
- bin += String.fromCharCode(bytes[i]);
2282
- }
2283
- // btoa is available in all Web Crypto runtimes (browsers, Workers, Node 16+).
2284
- const b64 = (typeof btoa === 'function')
2285
- ? btoa(bin)
2286
- : Buffer.from(bin, 'binary').toString('base64');
2287
- return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
2288
- }
2289
-
2290
-
2291
- /** Encode a JSON-serializable value as base64url (UTF-8 JSON). */
2292
- function jsonToBase64Url(value) {
2293
- return bytesToBase64Url(utf8(JSON.stringify(value)));
2294
- }
2295
-
2296
- /** Cryptographically random bytes. */
2297
- function randomBytes(n) {
2298
- const out = new Uint8Array(n);
2299
- globalThis.crypto.getRandomValues(out);
2300
- return out;
2301
- }
2302
-
2303
2376
  /**
2304
2377
  * Web Push — RFC 8030 (transport) + RFC 8291 (aes128gcm payload encryption)
2305
2378
  * + RFC 8292 (VAPID).