@vex-chat/libvex 6.8.0 → 7.0.1

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.
package/dist/Client.js CHANGED
@@ -269,6 +269,19 @@ const messageSchema = z.object({
269
269
  timestamp: z.string(),
270
270
  });
271
271
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
272
+ const MAIL_FANOUT_CONCURRENCY = 8;
273
+ const MAIL_BATCH_MAX_SIZE = 32;
274
+ const MAIL_BATCH_FLUSH_DELAY_MS = 8;
275
+ const mailBatchResponseSchema = z.object({
276
+ results: z.array(z.object({
277
+ error: z.string().optional(),
278
+ index: z.number().int().nonnegative(),
279
+ mailID: z.string().optional(),
280
+ ok: z.boolean(),
281
+ recipient: z.string().optional(),
282
+ status: z.number().int().optional(),
283
+ })),
284
+ });
272
285
  function decodeMessageBlob(body) {
273
286
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
274
287
  return { message: body };
@@ -535,16 +548,15 @@ export class Client {
535
548
  * Passkey ("recovery credential") methods.
536
549
  *
537
550
  * Passkeys are an account-bound second-class credential that can
538
- * authenticate the owning user, list devices, delete devices, and
539
- * approve/reject pending device-enrollment requests — i.e.
540
- * provisioning + recovery. They cannot send/decrypt mail.
551
+ * authenticate the owning user, list devices, delete devices, recover a
552
+ * pending device enrollment, and reject pending device-enrollment
553
+ * requests. They cannot send/decrypt mail.
541
554
  *
542
555
  * The host app drives the WebAuthn ceremony (e.g. via
543
556
  * `@simplewebauthn/browser`) and hands the JSON response to
544
557
  * `finish*`.
545
558
  */
546
559
  passkeys = {
547
- approveDeviceRequest: this.passkeyApproveDeviceRequest.bind(this),
548
560
  beginAuthentication: this.beginPasskeyAuthentication.bind(this),
549
561
  beginRegistration: this.beginPasskeyRegistration.bind(this),
550
562
  delete: this.deletePasskey.bind(this),
@@ -553,6 +565,7 @@ export class Client {
553
565
  finishRegistration: this.finishPasskeyRegistration.bind(this),
554
566
  list: this.listPasskeys.bind(this),
555
567
  listDevices: this.passkeyListDevices.bind(this),
568
+ recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
556
569
  rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
557
570
  };
558
571
  /**
@@ -668,6 +681,9 @@ export class Client {
668
681
  isAlive = true;
669
682
  localMessageRetentionDays;
670
683
  localRetentionPurgeTimer = null;
684
+ mailBatchFlushTimer = null;
685
+ mailBatchQueue = [];
686
+ mailBatchUnsupported = false;
671
687
  mailInterval;
672
688
  manuallyClosing = false;
673
689
  /* Retrieves the userID with the user identifier.
@@ -924,6 +940,14 @@ export class Client {
924
940
  if (this.mailInterval) {
925
941
  clearInterval(this.mailInterval);
926
942
  }
943
+ if (this.mailBatchFlushTimer) {
944
+ clearTimeout(this.mailBatchFlushTimer);
945
+ this.mailBatchFlushTimer = null;
946
+ }
947
+ const pendingMailBatch = this.mailBatchQueue.splice(0);
948
+ for (const pending of pendingMailBatch) {
949
+ pending.reject(new Error("Client closed before mail batch sent."));
950
+ }
927
951
  if (this.localRetentionPurgeTimer) {
928
952
  clearInterval(this.localRetentionPurgeTimer);
929
953
  this.localRetentionPurgeTimer = null;
@@ -1479,7 +1503,8 @@ export class Client {
1479
1503
  // my keys
1480
1504
  const IK_A = this.xKeyRing.identityKeys.secretKey;
1481
1505
  const IK_AP = this.xKeyRing.identityKeys.publicKey;
1482
- const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
1506
+ const ephemeralKeys = await xBoxKeyPairAsync();
1507
+ const EK_A = ephemeralKeys.secretKey;
1483
1508
  const fips = this.cryptoProfile === "fips";
1484
1509
  // their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
1485
1510
  const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
@@ -1519,10 +1544,10 @@ export class Client {
1519
1544
  const nonce = xMakeNonce();
1520
1545
  const cipher = await xSecretboxAsync(message, nonce, SK);
1521
1546
  const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
1522
- const ephKeyWire = this.xKeyRing.ephemeralKeys.publicKey;
1547
+ const ephKeyWire = ephemeralKeys.publicKey;
1523
1548
  const extra = fips
1524
1549
  ? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
1525
- : xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
1550
+ : xConcat(this.signKeys.publicKey, ephemeralKeys.publicKey, PK, AD, IDX);
1526
1551
  const mail = {
1527
1552
  authorID: this.getUser().userID,
1528
1553
  cipher,
@@ -1544,8 +1569,6 @@ export class Client {
1544
1569
  transmissionID: uuid.v4(),
1545
1570
  type: "resource",
1546
1571
  };
1547
- // discard the ephemeral keys
1548
- await this.newEphemeralKeys();
1549
1572
  const ratchet = await initRatchetSession(SK, "initiator");
1550
1573
  const sessionEntry = {
1551
1574
  ...ratchet,
@@ -1585,33 +1608,7 @@ export class Client {
1585
1608
  if (shouldEmitHandshakeMessage) {
1586
1609
  this.emitter.emit("message", emitMsg);
1587
1610
  }
1588
- // send mail and wait for response
1589
- await new Promise((res, rej) => {
1590
- const callback = (packedMsg) => {
1591
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
1592
- if (receivedMsg.transmissionID === msg.transmissionID) {
1593
- this.socket.off("message", callback);
1594
- const parsed = WSMessageSchema.safeParse(receivedMsg);
1595
- if (parsed.success && parsed.data.type === "success") {
1596
- res(parsed.data.data);
1597
- }
1598
- else {
1599
- rej(new Error("Mail delivery failed: " +
1600
- JSON.stringify(receivedMsg)));
1601
- }
1602
- }
1603
- };
1604
- this.socket.on("message", callback);
1605
- // Forward send failures to the outer promise instead
1606
- // of leaking them as an unhandled rejection: the
1607
- // listener above can never resolve if the send didn't
1608
- // make it onto the wire, so without this the caller
1609
- // would hang for the full 30s send-loop timeout.
1610
- this.send(msg, hmac).catch((err) => {
1611
- this.socket.off("message", callback);
1612
- rej(err instanceof Error ? err : new Error(String(err)));
1613
- });
1614
- });
1611
+ await this.deliverMailResource(msg, hmac, mail);
1615
1612
  });
1616
1613
  }
1617
1614
  async deleteChannel(channelID) {
@@ -1641,6 +1638,49 @@ export class Client {
1641
1638
  async deleteServer(serverID) {
1642
1639
  await this.http.delete(this.getHost() + "/server/" + serverID);
1643
1640
  }
1641
+ deliverMailResource(msg, header, mail) {
1642
+ if (this.mailBatchUnsupported) {
1643
+ return this.deliverMailResourceOverSocket(msg, header);
1644
+ }
1645
+ return new Promise((resolve, reject) => {
1646
+ this.mailBatchQueue.push({
1647
+ header,
1648
+ mail,
1649
+ msg,
1650
+ reject,
1651
+ resolve,
1652
+ });
1653
+ if (this.mailBatchQueue.length >= MAIL_BATCH_MAX_SIZE) {
1654
+ void this.flushMailBatchQueue();
1655
+ }
1656
+ else {
1657
+ this.scheduleMailBatchFlush();
1658
+ }
1659
+ });
1660
+ }
1661
+ async deliverMailResourceOverSocket(msg, header) {
1662
+ await new Promise((res, rej) => {
1663
+ const callback = (packedMsg) => {
1664
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
1665
+ if (receivedMsg.transmissionID === msg.transmissionID) {
1666
+ this.socket.off("message", callback);
1667
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
1668
+ if (parsed.success && parsed.data.type === "success") {
1669
+ res();
1670
+ }
1671
+ else {
1672
+ rej(new Error("Mail delivery failed: " +
1673
+ JSON.stringify(receivedMsg)));
1674
+ }
1675
+ }
1676
+ };
1677
+ this.socket.on("message", callback);
1678
+ this.send(msg, header).catch((err) => {
1679
+ this.socket.off("message", callback);
1680
+ rej(err instanceof Error ? err : new Error(String(err)));
1681
+ });
1682
+ });
1683
+ }
1644
1684
  deviceListFailureDetail(err) {
1645
1685
  if (!isHttpError(err)) {
1646
1686
  return "";
@@ -1767,6 +1807,64 @@ export class Client {
1767
1807
  const response = await this.http.post(this.getHost() + "/user/" + userID + "/passkeys/register/finish", msgpack.encode(args), { headers: { "Content-Type": "application/msgpack" } });
1768
1808
  return decodeHttpResponse(PasskeyCodec, response.data);
1769
1809
  }
1810
+ async flushMailBatchOverSocket(batch) {
1811
+ await Promise.all(batch.map(async (item) => {
1812
+ try {
1813
+ await this.deliverMailResourceOverSocket(item.msg, item.header);
1814
+ item.resolve();
1815
+ }
1816
+ catch (err) {
1817
+ item.reject(err);
1818
+ }
1819
+ }));
1820
+ }
1821
+ async flushMailBatchQueue() {
1822
+ if (this.mailBatchFlushTimer) {
1823
+ clearTimeout(this.mailBatchFlushTimer);
1824
+ this.mailBatchFlushTimer = null;
1825
+ }
1826
+ const batch = this.mailBatchQueue.splice(0, MAIL_BATCH_MAX_SIZE);
1827
+ if (this.mailBatchQueue.length > 0) {
1828
+ this.scheduleMailBatchFlush();
1829
+ }
1830
+ if (batch.length === 0) {
1831
+ return;
1832
+ }
1833
+ if (this.mailBatchUnsupported) {
1834
+ await this.flushMailBatchOverSocket(batch);
1835
+ return;
1836
+ }
1837
+ try {
1838
+ const response = await this.http.post(this.getHost() + "/mail/batch", msgpack.encode({
1839
+ mails: batch.map((item) => ({
1840
+ header: item.header,
1841
+ mail: item.mail,
1842
+ })),
1843
+ }), { headers: { "Content-Type": "application/msgpack" } });
1844
+ const decoded = mailBatchResponseSchema.parse(msgpack.decode(new Uint8Array(response.data)));
1845
+ const resultsByIndex = new Map(decoded.results.map((result) => [result.index, result]));
1846
+ for (const [index, item] of batch.entries()) {
1847
+ const result = resultsByIndex.get(index);
1848
+ if (result?.ok === true) {
1849
+ item.resolve();
1850
+ continue;
1851
+ }
1852
+ item.reject(new Error("Mail delivery failed: " +
1853
+ (result?.error ??
1854
+ `missing batch result for index ${String(index)}`)));
1855
+ }
1856
+ }
1857
+ catch (err) {
1858
+ if (isHttpError(err) && err.response?.status === 404) {
1859
+ this.mailBatchUnsupported = true;
1860
+ await this.flushMailBatchOverSocket(batch);
1861
+ return;
1862
+ }
1863
+ for (const item of batch) {
1864
+ item.reject(err);
1865
+ }
1866
+ }
1867
+ }
1770
1868
  async forward(message) {
1771
1869
  if (this.isManualCloseInFlight()) {
1772
1870
  return;
@@ -1784,16 +1882,17 @@ export class Client {
1784
1882
  }
1785
1883
  const msgBytes = Uint8Array.from(msgpack.encode(copy));
1786
1884
  const devices = await this.fetchUserDeviceListWithBackoff(this.getUser().userID, "own");
1787
- for (const device of devices) {
1788
- if (device.deviceID === this.getDevice().deviceID) {
1789
- continue;
1790
- }
1791
- try {
1792
- await this.sendMailWithRecovery(device, this.getUser(), msgBytes, null, copy.mailID, true);
1793
- }
1794
- catch {
1795
- /* best-effort per device; parallel handshakes share ephemeral state */
1796
- }
1885
+ const targetDevices = devices.filter((device) => device.deviceID !== this.getDevice().deviceID);
1886
+ for (let index = 0; index < targetDevices.length; index += MAIL_FANOUT_CONCURRENCY) {
1887
+ const batch = targetDevices.slice(index, index + MAIL_FANOUT_CONCURRENCY);
1888
+ await Promise.all(batch.map(async (device) => {
1889
+ try {
1890
+ await this.sendMailWithRecovery(device, this.getUser(), msgBytes, null, copy.mailID, true);
1891
+ }
1892
+ catch {
1893
+ /* best-effort per device */
1894
+ }
1895
+ }));
1797
1896
  }
1798
1897
  }
1799
1898
  async getChannelByID(channelID) {
@@ -2093,7 +2192,6 @@ export class Client {
2093
2192
  this.scheduleReconnect();
2094
2193
  return true;
2095
2194
  }
2096
- // ── Passkeys ────────────────────────────────────────────────────────
2097
2195
  /**
2098
2196
  * Initializes the keyring. This must be called before anything else.
2099
2197
  */
@@ -2108,7 +2206,6 @@ export class Client {
2108
2206
  this.localRetentionPurgeTimer = setInterval(() => void this.runLocalRetentionPurge(), 6 * 60 * 60 * 1000);
2109
2207
  this.emitter.emit("ready");
2110
2208
  }
2111
- // ── Passkeys ────────────────────────────────────────────────────────
2112
2209
  initSocket() {
2113
2210
  try {
2114
2211
  if (!this.token) {
@@ -2263,15 +2360,6 @@ export class Client {
2263
2360
  }
2264
2361
  await this.submitOTK(needs);
2265
2362
  }
2266
- async newEphemeralKeys() {
2267
- if (!this.xKeyRing) {
2268
- if (this.manuallyClosing) {
2269
- return;
2270
- }
2271
- throw new Error("Key ring not initialized.");
2272
- }
2273
- this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
2274
- }
2275
2363
  /**
2276
2364
  * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2277
2365
  * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
@@ -2293,16 +2381,6 @@ export class Client {
2293
2381
  void this.database.saveMessage(message);
2294
2382
  this.scheduleRetentionPurge();
2295
2383
  };
2296
- async passkeyApproveDeviceRequest(requestID) {
2297
- const userID = this.getUser().userID;
2298
- const response = await this.http.post(this.getHost() +
2299
- "/user/" +
2300
- userID +
2301
- "/passkey/devices/requests/" +
2302
- requestID +
2303
- "/approve");
2304
- return decodeHttpResponse(DeviceCodec, response.data);
2305
- }
2306
2384
  async passkeyDeleteDevice(deviceID) {
2307
2385
  const userID = this.getUser().userID;
2308
2386
  await this.http.delete(this.getHost() + "/user/" + userID + "/passkey/devices/" + deviceID);
@@ -2312,6 +2390,15 @@ export class Client {
2312
2390
  const response = await this.http.get(this.getHost() + "/user/" + userID + "/passkey/devices");
2313
2391
  return decodeHttpResponse(DeviceArrayCodec, response.data);
2314
2392
  }
2393
+ async passkeyRecoverDeviceRequest(requestID) {
2394
+ const userID = this.getUser().userID;
2395
+ const response = await this.http.post(this.getHost() +
2396
+ "/user/" +
2397
+ userID +
2398
+ "/passkey/recover/devices/requests/" +
2399
+ requestID);
2400
+ return decodeHttpResponse(DeviceCodec, response.data);
2401
+ }
2315
2402
  async passkeyRejectDeviceRequest(requestID) {
2316
2403
  const userID = this.getUser().userID;
2317
2404
  await this.http.post(this.getHost() +
@@ -3116,6 +3203,15 @@ export class Client {
3116
3203
  leaveCryptoProfileScope();
3117
3204
  }
3118
3205
  }
3206
+ scheduleMailBatchFlush() {
3207
+ if (this.mailBatchFlushTimer) {
3208
+ return;
3209
+ }
3210
+ this.mailBatchFlushTimer = setTimeout(() => {
3211
+ this.mailBatchFlushTimer = null;
3212
+ void this.flushMailBatchQueue();
3213
+ }, MAIL_BATCH_FLUSH_DELAY_MS);
3214
+ }
3119
3215
  scheduleReconnect() {
3120
3216
  if (!this.autoReconnectEnabled ||
3121
3217
  this.isManualCloseInFlight() ||
@@ -3235,20 +3331,31 @@ export class Client {
3235
3331
  const stableDevices = [...targetDevices.values()].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
3236
3332
  let failCount = 0;
3237
3333
  let lastErr;
3238
- for (const device of stableDevices) {
3239
- const ownerRecord = device.owner === myUserID
3240
- ? this.getUser()
3241
- : this.userRecords[device.owner];
3242
- if (!ownerRecord) {
3243
- failCount += 1;
3244
- continue;
3245
- }
3246
- try {
3247
- await this.sendMailWithRecovery(device, ownerRecord, msgBytes, uuidToUint8(channelID), mailID, false);
3334
+ for (let index = 0; index < stableDevices.length; index += MAIL_FANOUT_CONCURRENCY) {
3335
+ const batch = stableDevices.slice(index, index + MAIL_FANOUT_CONCURRENCY);
3336
+ const results = await Promise.all(batch.map(async (device) => {
3337
+ const ownerRecord = device.owner === myUserID
3338
+ ? this.getUser()
3339
+ : this.userRecords[device.owner];
3340
+ if (!ownerRecord) {
3341
+ return new Error(`Missing owner record for device ${device.deviceID}.`);
3342
+ }
3343
+ try {
3344
+ await this.sendMailWithRecovery(device, ownerRecord, msgBytes, uuidToUint8(channelID), mailID, false);
3345
+ return undefined;
3346
+ }
3347
+ catch (e) {
3348
+ return e;
3349
+ }
3350
+ }));
3351
+ for (const result of results) {
3352
+ if (result !== undefined) {
3353
+ lastErr = result;
3354
+ failCount += 1;
3355
+ }
3248
3356
  }
3249
- catch (e) {
3250
- lastErr = e;
3251
- failCount += 1;
3357
+ if (failCount === stableDevices.length) {
3358
+ break;
3252
3359
  }
3253
3360
  }
3254
3361
  if (failCount === stableDevices.length) {
@@ -3370,31 +3477,7 @@ export class Client {
3370
3477
  };
3371
3478
  await this.database.saveSession(persisted);
3372
3479
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
3373
- await new Promise((res, rej) => {
3374
- const callback = (packedMsg) => {
3375
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
3376
- if (receivedMsg.transmissionID === msgb.transmissionID) {
3377
- this.socket.off("message", callback);
3378
- const parsed = WSMessageSchema.safeParse(receivedMsg);
3379
- if (parsed.success && parsed.data.type === "success") {
3380
- res(parsed.data.data);
3381
- }
3382
- else {
3383
- rej(new Error("Mail delivery failed: " +
3384
- JSON.stringify(receivedMsg)));
3385
- }
3386
- }
3387
- };
3388
- this.socket.on("message", callback);
3389
- // See the matching block above (sendMail handshake):
3390
- // forward send failures to the outer promise so the
3391
- // caller doesn't hang waiting for a response we never
3392
- // sent.
3393
- this.send(msgb, hmac).catch((err) => {
3394
- this.socket.off("message", callback);
3395
- rej(err instanceof Error ? err : new Error(String(err)));
3396
- });
3397
- });
3480
+ await this.deliverMailResource(msgb, hmac, mail);
3398
3481
  }
3399
3482
  finally {
3400
3483
  this.sending.delete(device.deviceID);
@@ -3464,28 +3547,41 @@ export class Client {
3464
3547
  // One logical DM fan-outs to multiple recipient devices. Reuse a
3465
3548
  // single mailID so local/UI dedupe treats it as one message.
3466
3549
  const messageMailID = uuid.v4();
3467
- for (const device of deviceList) {
3468
- try {
3469
- if (libvexDebugDmEnabled()) {
3470
- debugLibvexDm("sendMessage: sendMail start", {
3471
- mailID: messageMailID,
3472
- recipientDevice: device.deviceID,
3473
- });
3550
+ const msgBytes = XUtils.decodeUTF8(payload);
3551
+ for (let index = 0; index < deviceList.length; index += MAIL_FANOUT_CONCURRENCY) {
3552
+ const batch = deviceList.slice(index, index + MAIL_FANOUT_CONCURRENCY);
3553
+ const results = await Promise.all(batch.map(async (device) => {
3554
+ try {
3555
+ if (libvexDebugDmEnabled()) {
3556
+ debugLibvexDm("sendMessage: sendMail start", {
3557
+ mailID: messageMailID,
3558
+ recipientDevice: device.deviceID,
3559
+ });
3560
+ }
3561
+ await this.sendMailWithRecovery(device, userEntry, msgBytes, null, messageMailID, false);
3562
+ if (libvexDebugDmEnabled()) {
3563
+ debugLibvexDm("sendMessage: sendMail ok", {
3564
+ recipientDevice: device.deviceID,
3565
+ });
3566
+ }
3567
+ return undefined;
3474
3568
  }
3475
- await this.sendMailWithRecovery(device, userEntry, XUtils.decodeUTF8(payload), null, messageMailID, false);
3476
- if (libvexDebugDmEnabled()) {
3477
- debugLibvexDm("sendMessage: sendMail ok", {
3478
- recipientDevice: device.deviceID,
3479
- });
3569
+ catch (e) {
3570
+ if (libvexDebugDmEnabled()) {
3571
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
3572
+ console.error("[libvex:debug-dm] sendMessage: sendMail failed for device", device.deviceID, e);
3573
+ }
3574
+ return e;
3480
3575
  }
3481
- }
3482
- catch (e) {
3483
- if (libvexDebugDmEnabled()) {
3484
- // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
3485
- console.error("[libvex:debug-dm] sendMessage: sendMail failed for device", device.deviceID, e);
3576
+ }));
3577
+ for (const result of results) {
3578
+ if (result !== undefined) {
3579
+ lastErr = result;
3580
+ failCount += 1;
3486
3581
  }
3487
- lastErr = e;
3488
- failCount += 1;
3582
+ }
3583
+ if (failCount === deviceList.length) {
3584
+ break;
3489
3585
  }
3490
3586
  }
3491
3587
  if (failCount > 0) {