@vex-chat/libvex 7.2.0 → 7.3.0

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
@@ -4,7 +4,7 @@
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
6
  import { enterCryptoProfileScope, getCryptoProfile, leaveCryptoProfileScope, setCryptoProfile, xBoxKeyPairAsync, xBoxKeyPairFromSecretAsync, xConcat, xConstants, xDHAsync, xEcdhKeyPairFromEcdsaKeyPairAsync, xEncode, xHMAC, xKDF, XKeyConvert, xMakeNonce, xMnemonic, xRandomBytes, xSecretboxAsync, xSecretboxOpenAsync, xSignAsync, xSignKeyPair, xSignKeyPairAsync, xSignKeyPairFromSecret, xSignKeyPairFromSecretAsync, xSignOpenAsync, XUtils, } from "@vex-chat/crypto";
7
- import { CallEventSchema, CallSessionSchema, IceServerConfigSchema, MailType, MailWSSchema, PermissionSchema, WSMessageSchema, } from "@vex-chat/types";
7
+ import { CallEnvelopeBodySchema, CallEventSchema, IceServerConfigSchema, MailType, MailWSSchema, PermissionSchema, SignedCallEnvelopeSchema, WSMessageSchema, } from "@vex-chat/types";
8
8
  import { EventEmitter } from "eventemitter3";
9
9
  import * as uuid from "uuid";
10
10
  import { z } from "zod/v4";
@@ -74,15 +74,6 @@ function debugLibvexDm(msg, data) {
74
74
  // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
75
75
  console.error(`[libvex:debug-dm] ${payload}`);
76
76
  }
77
- function errorFromUnknown(err) {
78
- if (err instanceof Error) {
79
- return err;
80
- }
81
- if (typeof err === "string") {
82
- return new Error(err);
83
- }
84
- return new Error(JSON.stringify(err));
85
- }
86
77
  function ignoreSocketTeardown(err) {
87
78
  if (err instanceof WebSocketNotOpenError)
88
79
  return;
@@ -208,8 +199,13 @@ import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, C
208
199
  import { sqlSessionToCrypto } from "./utils/sqlSessionToCrypto.js";
209
200
  import { uuidToUint8 } from "./utils/uint8uuid.js";
210
201
  const _protocolMsgRegex = /��\w+:\w+��/g;
202
+ const NotificationSubscriptionChannelSchema = z.enum([
203
+ "apnsVoip",
204
+ "expo",
205
+ "fcmCall",
206
+ ]);
211
207
  const NotificationSubscriptionSchema = z.object({
212
- channel: z.literal("expo"),
208
+ channel: NotificationSubscriptionChannelSchema,
213
209
  createdAt: z.string(),
214
210
  deviceID: z.string(),
215
211
  enabled: z.boolean(),
@@ -277,6 +273,9 @@ const messageSchema = z.object({
277
273
  sender: z.string(),
278
274
  timestamp: z.string(),
279
275
  });
276
+ const CALL_ENVELOPE_PREFIX = "vex-call:1\n";
277
+ const CALL_INVITE_TTL_MS = 60_000;
278
+ const CALL_MAX_TTL_MS = 2 * 60 * 60 * 1000;
280
279
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
281
280
  const MAIL_FANOUT_CONCURRENCY = 8;
282
281
  const MAIL_BATCH_MAX_SIZE = 32;
@@ -291,6 +290,52 @@ const mailBatchResponseSchema = z.object({
291
290
  status: z.number().int().optional(),
292
291
  })),
293
292
  });
293
+ const callWakeNotifyData = z.object({
294
+ callID: z.string(),
295
+ expiresAt: z.string().optional(),
296
+ mailID: z.string().optional(),
297
+ mailNonce: z.string().optional(),
298
+ });
299
+ function canonicalizeJson(value) {
300
+ if (Array.isArray(value)) {
301
+ return value.map((item) => canonicalizeJson(item));
302
+ }
303
+ if (!isRecord(value)) {
304
+ return value;
305
+ }
306
+ const out = {};
307
+ for (const key of Object.keys(value).sort()) {
308
+ const item = value[key];
309
+ if (item !== undefined) {
310
+ out[key] = canonicalizeJson(item);
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+ function canonicalJsonBytes(value) {
316
+ return XUtils.decodeUTF8(JSON.stringify(canonicalizeJson(jsonWireValue(value))));
317
+ }
318
+ function cloneCallSession(session) {
319
+ return {
320
+ ...session,
321
+ participants: session.participants.map((participant) => ({
322
+ ...participant,
323
+ })),
324
+ };
325
+ }
326
+ function decodeCallEnvelopePlaintext(plaintext) {
327
+ if (!plaintext.startsWith(CALL_ENVELOPE_PREFIX)) {
328
+ return null;
329
+ }
330
+ try {
331
+ const raw = JSON.parse(plaintext.slice(CALL_ENVELOPE_PREFIX.length));
332
+ const parsed = SignedCallEnvelopeSchema.safeParse(raw);
333
+ return parsed.success ? parsed.data : null;
334
+ }
335
+ catch {
336
+ return null;
337
+ }
338
+ }
294
339
  function decodeMessageBlob(body) {
295
340
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
296
341
  return { message: body };
@@ -324,6 +369,9 @@ function decodeMessagePlaintext(plaintext) {
324
369
  }
325
370
  : blob;
326
371
  }
372
+ function encodeCallEnvelopePlaintext(envelope) {
373
+ return XUtils.decodeUTF8(CALL_ENVELOPE_PREFIX + JSON.stringify(envelope));
374
+ }
327
375
  function encodeMessagePlaintext(message, opts) {
328
376
  const body = opts?.extra === undefined
329
377
  ? message
@@ -334,6 +382,12 @@ function encodeMessagePlaintext(message, opts) {
334
382
  });
335
383
  return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
336
384
  }
385
+ function jsonWireValue(value) {
386
+ // Match the payload JSON.stringify will send, including RTC toJSON() output.
387
+ const encoded = JSON.stringify(value);
388
+ const parsed = JSON.parse(encoded);
389
+ return parsed;
390
+ }
337
391
  function messageFromDecodedPlaintext(decoded) {
338
392
  return {
339
393
  ...(decoded.extra !== undefined ? { extra: decoded.extra } : {}),
@@ -343,6 +397,9 @@ function messageFromDecodedPlaintext(decoded) {
343
397
  : {}),
344
398
  };
345
399
  }
400
+ function normalizeCallEnvelopeBodyForWire(body) {
401
+ return CallEnvelopeBodySchema.parse(jsonWireValue(body));
402
+ }
346
403
  function normalizeForwardedMessage(message) {
347
404
  const decoded = decodeMessagePlaintext(message.message);
348
405
  return {
@@ -396,22 +453,15 @@ export class Client {
396
453
  * authenticated signaling and call state over Spire.
397
454
  */
398
455
  calls = {
399
- accept: (callID, signal) => this.sendCallResource("ACCEPT", {
400
- callID,
401
- ...(signal ? { signal } : {}),
402
- }),
456
+ accept: (callID, signal) => this.sendEncryptedCallAction("accept", callID, signal),
403
457
  active: this.fetchActiveCalls.bind(this),
404
- cancel: (callID) => this.sendCallResource("CANCEL", { callID }),
405
- hangup: (callID) => this.sendCallResource("HANGUP", { callID }),
406
- ice: (callID, signal) => this.sendCallResource("ICE", { callID, signal }),
458
+ cancel: (callID) => this.sendEncryptedCallAction("cancel", callID),
459
+ hangup: (callID) => this.sendEncryptedCallAction("hangup", callID),
460
+ ice: (callID, signal) => this.sendEncryptedCallAction("ice", callID, signal),
407
461
  iceServers: this.fetchIceServers.bind(this),
408
- reject: (callID) => this.sendCallResource("REJECT", { callID }),
409
- signal: (callID, signal) => this.sendCallResource("SIGNAL", { callID, signal }),
410
- startDM: (recipientUserID, signal) => this.sendCallResource("INVITE", {
411
- conversationType: "dm",
412
- recipientUserID,
413
- ...(signal ? { signal } : {}),
414
- }),
462
+ reject: (callID) => this.sendEncryptedCallAction("reject", callID),
463
+ signal: (callID, signal) => this.sendEncryptedCallAction("signal", callID, signal),
464
+ startDM: (recipientUserID, signal) => this.startEncryptedDmCall(recipientUserID, signal),
415
465
  };
416
466
  /**
417
467
  * Browser-safe NODE_ENV accessor.
@@ -697,6 +747,7 @@ export class Client {
697
747
  retrieve: this.fetchUser.bind(this),
698
748
  };
699
749
  autoReconnectEnabled = false;
750
+ callStates = new Map();
700
751
  cryptoProfile;
701
752
  database;
702
753
  dbPath;
@@ -1389,6 +1440,86 @@ export class Client {
1389
1440
  this.emitUndecryptedMessage(mail, timestamp);
1390
1441
  this.acknowledgeInboundMail(mail);
1391
1442
  }
1443
+ applyCallEnvelopeBody(body) {
1444
+ const localUserID = this.getUser().userID;
1445
+ const peerUserID = body.fromUserID === localUserID ? body.toUserID : body.fromUserID;
1446
+ const peerDeviceID = body.fromUserID === localUserID
1447
+ ? body.toDeviceID
1448
+ : body.fromDeviceID;
1449
+ let state = this.callStates.get(body.callID);
1450
+ if (!state) {
1451
+ state = {
1452
+ peerDeviceID,
1453
+ peerUserID,
1454
+ pendingPeerDevices: [],
1455
+ sequence: 0,
1456
+ session: this.sessionFromCallEnvelope(body),
1457
+ };
1458
+ }
1459
+ state.peerUserID = peerUserID;
1460
+ if (body.fromUserID !== localUserID || body.action === "accept") {
1461
+ state.peerDeviceID = peerDeviceID;
1462
+ }
1463
+ state.sequence = Math.max(state.sequence, body.sequence);
1464
+ state.session.expiresAt = body.expiresAt;
1465
+ const now = new Date().toISOString();
1466
+ switch (body.action) {
1467
+ case "accept":
1468
+ state.session.status = "active";
1469
+ this.upsertCallParticipant(state.session, {
1470
+ acceptedAt: now,
1471
+ deviceID: body.fromDeviceID,
1472
+ joinedAt: now,
1473
+ state: "accepted",
1474
+ userID: body.fromUserID,
1475
+ });
1476
+ break;
1477
+ case "cancel":
1478
+ case "end":
1479
+ case "hangup":
1480
+ case "reject":
1481
+ case "timeout":
1482
+ state.session.status = "ended";
1483
+ state.session.endedAt = now;
1484
+ this.upsertCallParticipant(state.session, {
1485
+ leftAt: now,
1486
+ state: body.action === "reject" ? "rejected" : "left",
1487
+ userID: body.fromUserID,
1488
+ });
1489
+ break;
1490
+ case "ice":
1491
+ case "signal":
1492
+ break;
1493
+ case "invite":
1494
+ state.session.status = "ringing";
1495
+ this.upsertCallParticipant(state.session, {
1496
+ acceptedAt: body.createdAt,
1497
+ deviceID: body.createdByDeviceID,
1498
+ joinedAt: body.createdAt,
1499
+ state: "accepted",
1500
+ userID: body.createdBy,
1501
+ });
1502
+ this.upsertCallParticipant(state.session, {
1503
+ state: "ringing",
1504
+ userID: body.toUserID,
1505
+ });
1506
+ break;
1507
+ }
1508
+ const event = {
1509
+ action: body.action,
1510
+ call: cloneCallSession(state.session),
1511
+ fromDeviceID: body.fromDeviceID,
1512
+ fromUserID: body.fromUserID,
1513
+ ...(body.signal ? { signal: body.signal } : {}),
1514
+ };
1515
+ if (state.session.status === "ended") {
1516
+ this.callStates.delete(body.callID);
1517
+ }
1518
+ else {
1519
+ this.callStates.set(body.callID, state);
1520
+ }
1521
+ return event;
1522
+ }
1392
1523
  async approveDeviceRequest(requestID) {
1393
1524
  const req = await this.getDeviceRegistrationRequest(requestID);
1394
1525
  if (!req) {
@@ -1425,6 +1556,35 @@ export class Client {
1425
1556
  "/passkeys/register/begin", msgpack.encode({ name: args.name, signed }), { headers: { "Content-Type": "application/msgpack" } });
1426
1557
  return decodeHttpResponse(PasskeyOptionsCodec, response.data);
1427
1558
  }
1559
+ async callEnvelopeForBody(body) {
1560
+ const wireBody = normalizeCallEnvelopeBodyForWire(body);
1561
+ const signed = await xSignAsync(canonicalJsonBytes(wireBody), this.signKeys.secretKey);
1562
+ return {
1563
+ body: wireBody,
1564
+ signed: XUtils.encodeHex(signed),
1565
+ };
1566
+ }
1567
+ async callTargetsForState(state) {
1568
+ if (state.peerDeviceID) {
1569
+ const cached = this.deviceRecords[state.peerDeviceID];
1570
+ const device = cached ?? (await this.getDeviceByID(state.peerDeviceID));
1571
+ if (!device) {
1572
+ throw new Error(`Call peer device not found: ${state.peerDeviceID}`);
1573
+ }
1574
+ return [device];
1575
+ }
1576
+ return state.pendingPeerDevices;
1577
+ }
1578
+ callWakeForEnvelope(body) {
1579
+ if (body.action !== "invite") {
1580
+ return undefined;
1581
+ }
1582
+ return {
1583
+ callID: body.callID,
1584
+ event: "callWake",
1585
+ expiresAt: body.expiresAt,
1586
+ };
1587
+ }
1428
1588
  censorPreKey(preKey) {
1429
1589
  if (!preKey.index) {
1430
1590
  throw new Error("Key index is required.");
@@ -1527,7 +1687,7 @@ export class Client {
1527
1687
  * When `readMail` triggers a best-effort session re-establish, key-bundle
1528
1688
  * errors should not reject the full read pipeline.
1529
1689
  */
1530
- allowKeyBundleFailure = false) {
1690
+ allowKeyBundleFailure = false, notify) {
1531
1691
  return this.runWithThisCryptoProfile(async () => {
1532
1692
  let keyBundle;
1533
1693
  try {
@@ -1608,10 +1768,11 @@ export class Client {
1608
1768
  recipient: device.deviceID,
1609
1769
  sender: this.getDevice().deviceID,
1610
1770
  };
1771
+ const wireMail = notify ? { ...mail, notify } : mail;
1611
1772
  const hmac = xHMAC(mail, SK);
1612
1773
  const msg = {
1613
1774
  action: "CREATE",
1614
- data: mail,
1775
+ data: wireMail,
1615
1776
  resourceType: "mail",
1616
1777
  transmissionID: uuid.v4(),
1617
1778
  type: "resource",
@@ -1631,32 +1792,38 @@ export class Client {
1631
1792
  };
1632
1793
  await this.database.saveSession(sessionEntry);
1633
1794
  this.emitter.emit("session", sessionEntry, user);
1634
- // emit the message
1795
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
1796
+ const callEnvelope = forward
1797
+ ? null
1798
+ : decodeCallEnvelopePlaintext(rawPlaintext);
1635
1799
  const forwardedMsg = forward
1636
1800
  ? messageSchema.parse(msgpack.decode(message))
1637
1801
  : null;
1638
- const shouldEmitHandshakeMessage = forward || message.length > 0;
1639
1802
  const emitMsg = forwardedMsg
1640
1803
  ? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
1641
- : {
1642
- authorID: mail.authorID,
1643
- decrypted: true,
1644
- direction: "outgoing",
1645
- forward: mail.forward,
1646
- group: mail.group ? uuid.stringify(mail.group) : null,
1647
- mailID: mail.mailID,
1648
- ...messageFromDecodedPlaintext(decodeMessagePlaintext(XUtils.encodeUTF8(message))),
1649
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1650
- readerID: mail.readerID,
1651
- recipient: mail.recipient,
1652
- sender: mail.sender,
1653
- timestamp: new Date().toISOString(),
1654
- };
1655
- if (shouldEmitHandshakeMessage) {
1804
+ : callEnvelope
1805
+ ? null
1806
+ : message.length > 0
1807
+ ? {
1808
+ authorID: mail.authorID,
1809
+ decrypted: true,
1810
+ direction: "outgoing",
1811
+ forward: mail.forward,
1812
+ group: mail.group ? uuid.stringify(mail.group) : null,
1813
+ mailID: mail.mailID,
1814
+ ...messageFromDecodedPlaintext(decodeMessagePlaintext(rawPlaintext)),
1815
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1816
+ readerID: mail.readerID,
1817
+ recipient: mail.recipient,
1818
+ sender: mail.sender,
1819
+ timestamp: new Date().toISOString(),
1820
+ }
1821
+ : null;
1822
+ if (emitMsg) {
1656
1823
  this.emitter.emit("message", emitMsg);
1657
1824
  }
1658
- await this.deliverMailResource(msg, hmac, mail);
1659
- return shouldEmitHandshakeMessage ? emitMsg : null;
1825
+ await this.deliverMailResource(msg, hmac, wireMail);
1826
+ return emitMsg;
1660
1827
  });
1661
1828
  }
1662
1829
  async deleteChannel(channelID) {
@@ -1686,6 +1853,49 @@ export class Client {
1686
1853
  async deleteServer(serverID) {
1687
1854
  await this.http.delete(this.getHost() + "/server/" + serverID);
1688
1855
  }
1856
+ async deliverCallEnvelopeBatch(args) {
1857
+ let failCount = 0;
1858
+ let lastErr;
1859
+ for (let index = 0; index < args.bodies.length; index += MAIL_FANOUT_CONCURRENCY) {
1860
+ const batch = args.bodies.slice(index, index + MAIL_FANOUT_CONCURRENCY);
1861
+ const results = await Promise.all(batch.map(async (body) => {
1862
+ try {
1863
+ const targetDevice = this.deviceRecords[body.toDeviceID] ??
1864
+ (await this.getDeviceByID(body.toDeviceID));
1865
+ if (!targetDevice) {
1866
+ throw new Error(`Call target device not found: ${body.toDeviceID}`);
1867
+ }
1868
+ await this.sendCallEnvelopeMail({
1869
+ body,
1870
+ mailID: args.mailID,
1871
+ notify: this.callWakeForEnvelope(body),
1872
+ targetDevice,
1873
+ targetUser: args.targetUser,
1874
+ });
1875
+ return undefined;
1876
+ }
1877
+ catch (err) {
1878
+ return err;
1879
+ }
1880
+ }));
1881
+ for (const result of results) {
1882
+ if (result !== undefined) {
1883
+ lastErr = result;
1884
+ failCount += 1;
1885
+ }
1886
+ }
1887
+ }
1888
+ if (failCount > 0) {
1889
+ const base = lastErr instanceof Error ? lastErr : new Error(String(lastErr));
1890
+ if (failCount === args.bodies.length) {
1891
+ throw base;
1892
+ }
1893
+ const partial = new Error(`Call signaling failed to reach ${String(failCount)} of ` +
1894
+ `${String(args.bodies.length)} peer device(s).`);
1895
+ partial.cause = base;
1896
+ throw partial;
1897
+ }
1898
+ }
1689
1899
  deliverMailResource(msg, header, mail) {
1690
1900
  if (this.mailBatchUnsupported) {
1691
1901
  return this.deliverMailResourceOverSocket(msg, header);
@@ -1758,12 +1968,50 @@ export class Client {
1758
1968
  timestamp,
1759
1969
  });
1760
1970
  }
1761
- async fetchActiveCalls() {
1762
- const res = await this.http.get(this.getHost() + "/calls/active", {
1763
- responseType: "json",
1764
- });
1765
- return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
1766
- .calls;
1971
+ fetchActiveCalls() {
1972
+ const now = Date.now();
1973
+ const active = [];
1974
+ for (const [callID, state] of this.callStates.entries()) {
1975
+ if (state.session.status === "ended" ||
1976
+ Date.parse(state.session.expiresAt) <= now) {
1977
+ this.callStates.delete(callID);
1978
+ continue;
1979
+ }
1980
+ active.push(cloneCallSession(state.session));
1981
+ }
1982
+ return Promise.resolve(active);
1983
+ }
1984
+ async fetchCallPeer(args) {
1985
+ const [user, err] = await this.fetchUser(args.userID);
1986
+ if (err) {
1987
+ throw err;
1988
+ }
1989
+ if (!user) {
1990
+ throw new Error("Call peer not found.");
1991
+ }
1992
+ const afterBackoff = await this.fetchUserDeviceListWithBackoff(args.userID, "peer");
1993
+ let deviceListRaw;
1994
+ try {
1995
+ const again = await this.fetchUserDeviceListOnce(args.userID);
1996
+ const byID = new Map();
1997
+ for (const device of afterBackoff) {
1998
+ byID.set(device.deviceID, device);
1999
+ }
2000
+ for (const device of again) {
2001
+ byID.set(device.deviceID, device);
2002
+ }
2003
+ deviceListRaw = [...byID.values()];
2004
+ }
2005
+ catch {
2006
+ deviceListRaw = afterBackoff;
2007
+ }
2008
+ const devices = deviceListRaw
2009
+ .filter((device) => !device.deleted)
2010
+ .sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
2011
+ if (devices.length === 0) {
2012
+ throw new Error("Call peer has no active devices.");
2013
+ }
2014
+ return { devices, user };
1767
2015
  }
1768
2016
  async fetchIceServers() {
1769
2017
  const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
@@ -1867,6 +2115,23 @@ export class Client {
1867
2115
  }
1868
2116
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1869
2117
  }
2118
+ async fetchUserOrThrow(userID) {
2119
+ if (userID === this.getUser().userID) {
2120
+ return this.getUser();
2121
+ }
2122
+ const cached = this.userRecords[userID];
2123
+ if (cached) {
2124
+ return cached;
2125
+ }
2126
+ const [user, err] = await this.fetchUser(userID);
2127
+ if (err) {
2128
+ throw err;
2129
+ }
2130
+ if (!user) {
2131
+ throw new Error(`User not found: ${userID}`);
2132
+ }
2133
+ return user;
2134
+ }
1870
2135
  /**
1871
2136
  * Finish a passkey login and adopt the resulting JWT as the
1872
2137
  * client's bearer token. After this call, `client.passkeys.*`
@@ -2253,6 +2518,14 @@ export class Client {
2253
2518
  }
2254
2519
  break;
2255
2520
  }
2521
+ case "callWake": {
2522
+ const parsed = callWakeNotifyData.safeParse(msg.data);
2523
+ await this.getMail();
2524
+ if (parsed.success) {
2525
+ this.emitter.emit("callWake", parsed.data);
2526
+ }
2527
+ break;
2528
+ }
2256
2529
  case "deviceRequest": {
2257
2530
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
2258
2531
  if (parsed.success) {
@@ -2470,6 +2743,67 @@ export class Client {
2470
2743
  const response = await this.http.get(this.getHost() + "/user/" + userID + "/passkeys");
2471
2744
  return decodeHttpResponse(PasskeyArrayCodec, response.data);
2472
2745
  }
2746
+ makeCallEnvelopeBody(args) {
2747
+ return {
2748
+ action: args.action,
2749
+ callID: args.state.session.callID,
2750
+ conversationID: args.state.session.conversationID,
2751
+ conversationType: args.state.session.conversationType,
2752
+ createdAt: args.state.session.createdAt,
2753
+ createdBy: args.state.session.createdBy,
2754
+ createdByDeviceID: args.state.session.createdByDeviceID,
2755
+ expiresAt: args.expiresAt,
2756
+ fromDeviceID: this.getDevice().deviceID,
2757
+ fromUserID: this.getUser().userID,
2758
+ media: "audio",
2759
+ sequence: args.sequence,
2760
+ ...(args.signal ? { signal: args.signal } : {}),
2761
+ toDeviceID: args.toDeviceID,
2762
+ toUserID: args.toUserID,
2763
+ version: 1,
2764
+ };
2765
+ }
2766
+ markLocalCallAction(state, action, signal) {
2767
+ const now = new Date().toISOString();
2768
+ if (action === "accept") {
2769
+ state.session.status = "active";
2770
+ state.session.expiresAt = new Date(Date.now() + CALL_MAX_TTL_MS).toISOString();
2771
+ this.upsertCallParticipant(state.session, {
2772
+ acceptedAt: now,
2773
+ deviceID: this.getDevice().deviceID,
2774
+ joinedAt: now,
2775
+ state: "accepted",
2776
+ userID: this.getUser().userID,
2777
+ });
2778
+ }
2779
+ else if (action === "cancel" ||
2780
+ action === "end" ||
2781
+ action === "hangup" ||
2782
+ action === "reject" ||
2783
+ action === "timeout") {
2784
+ state.session.status = "ended";
2785
+ state.session.endedAt = now;
2786
+ this.upsertCallParticipant(state.session, {
2787
+ leftAt: now,
2788
+ state: action === "reject" ? "rejected" : "left",
2789
+ userID: this.getUser().userID,
2790
+ });
2791
+ }
2792
+ const event = {
2793
+ action,
2794
+ call: cloneCallSession(state.session),
2795
+ fromDeviceID: this.getDevice().deviceID,
2796
+ fromUserID: this.getUser().userID,
2797
+ ...(signal ? { signal } : {}),
2798
+ };
2799
+ if (state.session.status === "ended") {
2800
+ this.callStates.delete(state.session.callID);
2801
+ }
2802
+ else {
2803
+ this.callStates.set(state.session.callID, state);
2804
+ }
2805
+ return event;
2806
+ }
2473
2807
  async markSessionVerified(sessionID) {
2474
2808
  return this.database.markSessionVerified(sessionID);
2475
2809
  }
@@ -2652,6 +2986,29 @@ export class Client {
2652
2986
  }
2653
2987
  }
2654
2988
  }
2989
+ async processDecryptedCallEnvelope(args) {
2990
+ const body = args.envelope.body;
2991
+ if (body.fromDeviceID !== args.mail.sender ||
2992
+ body.fromUserID !== args.mail.authorID ||
2993
+ body.toDeviceID !== args.mail.recipient ||
2994
+ body.toUserID !== args.mail.readerID ||
2995
+ body.toDeviceID !== this.getDevice().deviceID ||
2996
+ body.toUserID !== this.getUser().userID) {
2997
+ return null;
2998
+ }
2999
+ const senderDevice = await this.getDeviceByID(body.fromDeviceID);
3000
+ if (!senderDevice || senderDevice.owner !== body.fromUserID) {
3001
+ return null;
3002
+ }
3003
+ const opened = await xSignOpenAsync(XUtils.decodeHex(args.envelope.signed), XUtils.decodeHex(senderDevice.signKey));
3004
+ if (!opened) {
3005
+ return null;
3006
+ }
3007
+ if (!XUtils.bytesEqual(opened, canonicalJsonBytes(body))) {
3008
+ return null;
3009
+ }
3010
+ return this.applyCallEnvelopeBody(body);
3011
+ }
2655
3012
  async publishPendingDeviceRegistration(args) {
2656
3013
  const signed = await this.signPendingRegistrationChallenge(args.challenge);
2657
3014
  await this.http.post(this.getHost() +
@@ -2904,39 +3261,52 @@ export class Client {
2904
3261
  if (!mail.forward) {
2905
3262
  plaintext = XUtils.encodeUTF8(unsealed);
2906
3263
  }
2907
- const decodedPlaintext = mail.forward
3264
+ const callEnvelope = mail.forward
2908
3265
  ? null
2909
- : decodeMessagePlaintext(plaintext);
2910
- // emit the message
2911
- const fwdMsg1 = mail.forward
2912
- ? messageSchema.parse(msgpack.decode(unsealed))
2913
- : null;
2914
- const message = fwdMsg1
2915
- ? {
2916
- ...normalizeForwardedMessage(fwdMsg1),
2917
- forward: true,
3266
+ : decodeCallEnvelopePlaintext(plaintext);
3267
+ if (callEnvelope) {
3268
+ const event = await this.processDecryptedCallEnvelope({
3269
+ envelope: callEnvelope,
3270
+ mail,
3271
+ });
3272
+ if (event) {
3273
+ this.emitter.emit("call", event);
3274
+ }
3275
+ }
3276
+ else {
3277
+ const decodedPlaintext = mail.forward
3278
+ ? null
3279
+ : decodeMessagePlaintext(plaintext);
3280
+ const fwdMsg1 = mail.forward
3281
+ ? messageSchema.parse(msgpack.decode(unsealed))
3282
+ : null;
3283
+ const message = fwdMsg1
3284
+ ? {
3285
+ ...normalizeForwardedMessage(fwdMsg1),
3286
+ forward: true,
3287
+ }
3288
+ : {
3289
+ authorID: mail.authorID,
3290
+ decrypted: true,
3291
+ direction: "incoming",
3292
+ forward: mail.forward,
3293
+ group: mail.group
3294
+ ? uuid.stringify(mail.group)
3295
+ : null,
3296
+ mailID: mail.mailID,
3297
+ ...messageFromDecodedPlaintext(decodedPlaintext ?? {
3298
+ message: plaintext,
3299
+ }),
3300
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3301
+ readerID: mail.readerID,
3302
+ recipient: mail.recipient,
3303
+ sender: mail.sender,
3304
+ timestamp: timestamp,
3305
+ };
3306
+ const shouldEmitIncomingInitial = mail.forward || plaintext.length > 0;
3307
+ if (shouldEmitIncomingInitial) {
3308
+ this.emitter.emit("message", message);
2918
3309
  }
2919
- : {
2920
- authorID: mail.authorID,
2921
- decrypted: true,
2922
- direction: "incoming",
2923
- forward: mail.forward,
2924
- group: mail.group
2925
- ? uuid.stringify(mail.group)
2926
- : null,
2927
- mailID: mail.mailID,
2928
- ...messageFromDecodedPlaintext(decodedPlaintext ?? {
2929
- message: plaintext,
2930
- }),
2931
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2932
- readerID: mail.readerID,
2933
- recipient: mail.recipient,
2934
- sender: mail.sender,
2935
- timestamp: timestamp,
2936
- };
2937
- const shouldEmitIncomingInitial = mail.forward || plaintext.length > 0;
2938
- if (shouldEmitIncomingInitial) {
2939
- this.emitter.emit("message", message);
2940
3310
  }
2941
3311
  if (libvexDebugDmEnabled()) {
2942
3312
  try {
@@ -3102,33 +3472,47 @@ export class Client {
3102
3472
  ? messageSchema.parse(msgpack.decode(decrypted))
3103
3473
  : null;
3104
3474
  const rawIncoming = XUtils.encodeUTF8(decrypted);
3105
- const decodedPlaintext = mail.forward
3475
+ const callEnvelope = mail.forward
3106
3476
  ? null
3107
- : decodeMessagePlaintext(rawIncoming);
3108
- const message = fwdMsg2
3109
- ? {
3110
- ...normalizeForwardedMessage(fwdMsg2),
3111
- forward: true,
3477
+ : decodeCallEnvelopePlaintext(rawIncoming);
3478
+ if (callEnvelope) {
3479
+ const event = await this.processDecryptedCallEnvelope({
3480
+ envelope: callEnvelope,
3481
+ mail,
3482
+ });
3483
+ if (event) {
3484
+ this.emitter.emit("call", event);
3112
3485
  }
3113
- : {
3114
- authorID: mail.authorID,
3115
- decrypted: true,
3116
- direction: "incoming",
3117
- forward: mail.forward,
3118
- group: mail.group
3119
- ? uuid.stringify(mail.group)
3120
- : null,
3121
- mailID: mail.mailID,
3122
- ...messageFromDecodedPlaintext(decodedPlaintext ?? {
3123
- message: rawIncoming,
3124
- }),
3125
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3126
- readerID: mail.readerID,
3127
- recipient: mail.recipient,
3128
- sender: mail.sender,
3129
- timestamp: timestamp,
3130
- };
3131
- this.emitter.emit("message", message);
3486
+ }
3487
+ else {
3488
+ const decodedPlaintext = mail.forward
3489
+ ? null
3490
+ : decodeMessagePlaintext(rawIncoming);
3491
+ const message = fwdMsg2
3492
+ ? {
3493
+ ...normalizeForwardedMessage(fwdMsg2),
3494
+ forward: true,
3495
+ }
3496
+ : {
3497
+ authorID: mail.authorID,
3498
+ decrypted: true,
3499
+ direction: "incoming",
3500
+ forward: mail.forward,
3501
+ group: mail.group
3502
+ ? uuid.stringify(mail.group)
3503
+ : null,
3504
+ mailID: mail.mailID,
3505
+ ...messageFromDecodedPlaintext(decodedPlaintext ?? {
3506
+ message: rawIncoming,
3507
+ }),
3508
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3509
+ readerID: mail.readerID,
3510
+ recipient: mail.recipient,
3511
+ sender: mail.sender,
3512
+ timestamp: timestamp,
3513
+ };
3514
+ this.emitter.emit("message", message);
3515
+ }
3132
3516
  const sqlPatch = sessionToSqlPatch(session);
3133
3517
  const persisted = {
3134
3518
  CKr: sqlPatch.CKr,
@@ -3466,68 +3850,44 @@ export class Client {
3466
3850
  throw err;
3467
3851
  }
3468
3852
  }
3469
- async sendCallResource(action, data) {
3470
- const msg = {
3853
+ async sendCallEnvelopeMail(args) {
3854
+ const envelope = await this.callEnvelopeForBody(args.body);
3855
+ await this.sendMailWithRecovery(args.targetDevice, args.targetUser, encodeCallEnvelopePlaintext(envelope), null, args.mailID, false, false, args.notify);
3856
+ }
3857
+ async sendEncryptedCallAction(action, callID, signal) {
3858
+ const state = this.callStates.get(callID);
3859
+ if (!state) {
3860
+ throw new Error("Unknown encrypted call: " + callID);
3861
+ }
3862
+ const targets = await this.callTargetsForState(state);
3863
+ if (targets.length === 0) {
3864
+ throw new Error("Call has no reachable peer devices.");
3865
+ }
3866
+ const targetUser = await this.fetchUserOrThrow(state.peerUserID);
3867
+ const sequence = state.sequence + 1;
3868
+ state.sequence = sequence;
3869
+ const expiresAt = action === "accept"
3870
+ ? new Date(Date.now() + CALL_MAX_TTL_MS).toISOString()
3871
+ : state.session.expiresAt;
3872
+ const bodies = targets.map((target) => this.makeCallEnvelopeBody({
3471
3873
  action,
3472
- data,
3473
- resourceType: "call",
3474
- transmissionID: uuid.v4(),
3475
- type: "resource",
3476
- };
3477
- return await new Promise((resolve, reject) => {
3478
- const settle = (err, event) => {
3479
- this.socket.off("message", callback);
3480
- if (err !== null) {
3481
- reject(errorFromUnknown(err));
3482
- return;
3483
- }
3484
- if (!event) {
3485
- reject(new Error("Call signaling response was empty."));
3486
- return;
3487
- }
3488
- resolve(event);
3489
- };
3490
- const callback = (packedMsg) => {
3491
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
3492
- if (receivedMsg.transmissionID !== msg.transmissionID) {
3493
- return;
3494
- }
3495
- const parsed = WSMessageSchema.safeParse(receivedMsg);
3496
- if (!parsed.success) {
3497
- settle("Call signaling failed: " + JSON.stringify(receivedMsg));
3498
- return;
3499
- }
3500
- if (parsed.data.type === "success") {
3501
- const event = CallEventSchema.safeParse(parsed.data.data);
3502
- if (!event.success) {
3503
- settle("Invalid call signaling response: " +
3504
- JSON.stringify(event.error.issues));
3505
- return;
3506
- }
3507
- settle(null, event.data);
3508
- return;
3509
- }
3510
- if (parsed.data.type === "error") {
3511
- settle(new Error(parsed.data.error));
3512
- return;
3513
- }
3514
- if (parsed.data.type === "notify" &&
3515
- (parsed.data.event === "call" ||
3516
- parsed.data.event === "callInvite")) {
3517
- const event = CallEventSchema.safeParse(parsed.data.data);
3518
- if (event.success) {
3519
- settle(null, event.data);
3520
- }
3521
- return;
3522
- }
3523
- settle("Unexpected call signaling response: " +
3524
- JSON.stringify(parsed.data));
3525
- };
3526
- this.socket.on("message", callback);
3527
- this.send(msg).catch((err) => {
3528
- settle(err);
3529
- });
3874
+ expiresAt,
3875
+ sequence,
3876
+ signal,
3877
+ state,
3878
+ toDeviceID: target.deviceID,
3879
+ toUserID: target.owner,
3880
+ }));
3881
+ await this.deliverCallEnvelopeBatch({
3882
+ bodies,
3883
+ mailID: uuid.v4(),
3884
+ targetUser,
3530
3885
  });
3886
+ if (targets.length === 1) {
3887
+ state.peerDeviceID = targets[0]?.deviceID;
3888
+ }
3889
+ state.session.expiresAt = expiresAt;
3890
+ return this.markLocalCallAction(state, action, signal);
3531
3891
  }
3532
3892
  async sendGroupMessage(channelID, message, opts) {
3533
3893
  const userList = await this.getUserList(channelID);
@@ -3616,7 +3976,7 @@ export class Client {
3616
3976
  }
3617
3977
  }
3618
3978
  /* Sends encrypted mail to a user. */
3619
- async sendMail(device, user, msg, group, mailID, forward, retry = false) {
3979
+ async sendMail(device, user, msg, group, mailID, forward, retry = false, notify) {
3620
3980
  while (this.sending.has(device.deviceID)) {
3621
3981
  await sleep(100);
3622
3982
  }
@@ -3631,7 +3991,7 @@ export class Client {
3631
3991
  retry: String(retry),
3632
3992
  });
3633
3993
  }
3634
- const createdMessage = await this.createSession(device, user, msg, group, mailID, forward, false);
3994
+ const createdMessage = await this.createSession(device, user, msg, group, mailID, forward, false, notify);
3635
3995
  if (libvexDebugDmEnabled()) {
3636
3996
  debugLibvexDm("sendMail: createSession returned", {
3637
3997
  peerDevice: device.deviceID,
@@ -3670,34 +4030,43 @@ export class Client {
3670
4030
  recipient: device.deviceID,
3671
4031
  sender: this.getDevice().deviceID,
3672
4032
  };
4033
+ const wireMail = notify ? { ...mail, notify } : mail;
3673
4034
  const msgb = {
3674
4035
  action: "CREATE",
3675
- data: mail,
4036
+ data: wireMail,
3676
4037
  resourceType: "mail",
3677
4038
  transmissionID: uuid.v4(),
3678
4039
  type: "resource",
3679
4040
  };
3680
4041
  const hmac = xHMAC(mail, messageKey);
4042
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
4043
+ const callEnvelope = forward
4044
+ ? null
4045
+ : decodeCallEnvelopePlaintext(rawPlaintext);
3681
4046
  const fwdOut = forward
3682
4047
  ? messageSchema.parse(msgpack.decode(msg))
3683
4048
  : null;
3684
4049
  const outMsg = fwdOut
3685
4050
  ? { ...normalizeForwardedMessage(fwdOut), forward: true }
3686
- : {
3687
- authorID: mail.authorID,
3688
- decrypted: true,
3689
- direction: "outgoing",
3690
- forward: mail.forward,
3691
- group: mail.group ? uuid.stringify(mail.group) : null,
3692
- mailID: mail.mailID,
3693
- ...messageFromDecodedPlaintext(decodeMessagePlaintext(XUtils.encodeUTF8(msg))),
3694
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3695
- readerID: mail.readerID,
3696
- recipient: mail.recipient,
3697
- sender: mail.sender,
3698
- timestamp: new Date().toISOString(),
3699
- };
3700
- this.emitter.emit("message", outMsg);
4051
+ : callEnvelope
4052
+ ? null
4053
+ : {
4054
+ authorID: mail.authorID,
4055
+ decrypted: true,
4056
+ direction: "outgoing",
4057
+ forward: mail.forward,
4058
+ group: mail.group ? uuid.stringify(mail.group) : null,
4059
+ mailID: mail.mailID,
4060
+ ...messageFromDecodedPlaintext(decodeMessagePlaintext(rawPlaintext)),
4061
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
4062
+ readerID: mail.readerID,
4063
+ recipient: mail.recipient,
4064
+ sender: mail.sender,
4065
+ timestamp: new Date().toISOString(),
4066
+ };
4067
+ if (outMsg) {
4068
+ this.emitter.emit("message", outMsg);
4069
+ }
3701
4070
  const sqlPatch = sessionToSqlPatch(session);
3702
4071
  const persisted = {
3703
4072
  CKr: sqlPatch.CKr,
@@ -3722,22 +4091,22 @@ export class Client {
3722
4091
  };
3723
4092
  await this.database.saveSession(persisted);
3724
4093
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
3725
- await this.deliverMailResource(msgb, hmac, mail);
4094
+ await this.deliverMailResource(msgb, hmac, wireMail);
3726
4095
  return outMsg;
3727
4096
  }
3728
4097
  finally {
3729
4098
  this.sending.delete(device.deviceID);
3730
4099
  }
3731
4100
  }
3732
- async sendMailWithRecovery(device, user, msg, group, mailID, forward, forceFreshSession = false) {
4101
+ async sendMailWithRecovery(device, user, msg, group, mailID, forward, forceFreshSession = false, notify) {
3733
4102
  try {
3734
- return await this.sendMail(device, user, msg, group, mailID, forward, forceFreshSession);
4103
+ return await this.sendMail(device, user, msg, group, mailID, forward, forceFreshSession, notify);
3735
4104
  }
3736
4105
  catch (err) {
3737
4106
  if (!this.shouldRetryDeliveryWithFreshSession(err)) {
3738
4107
  throw err;
3739
4108
  }
3740
- return await this.sendMail(device, user, msg, group, mailID, forward, true);
4109
+ return await this.sendMail(device, user, msg, group, mailID, forward, true, notify);
3741
4110
  }
3742
4111
  }
3743
4112
  async sendMessage(userID, message, opts) {
@@ -3872,6 +4241,32 @@ export class Client {
3872
4241
  // Don't surface a teardown race as an unhandled rejection.
3873
4242
  this.send(receipt).catch(ignoreSocketTeardown);
3874
4243
  }
4244
+ sessionFromCallEnvelope(body) {
4245
+ const session = {
4246
+ callID: body.callID,
4247
+ conversationID: body.conversationID,
4248
+ conversationType: body.conversationType,
4249
+ createdAt: body.createdAt,
4250
+ createdBy: body.createdBy,
4251
+ createdByDeviceID: body.createdByDeviceID,
4252
+ expiresAt: body.expiresAt,
4253
+ media: "audio",
4254
+ participants: [],
4255
+ status: body.action === "invite" ? "ringing" : "active",
4256
+ };
4257
+ this.upsertCallParticipant(session, {
4258
+ acceptedAt: body.createdAt,
4259
+ deviceID: body.createdByDeviceID,
4260
+ joinedAt: body.createdAt,
4261
+ state: "accepted",
4262
+ userID: body.createdBy,
4263
+ });
4264
+ this.upsertCallParticipant(session, {
4265
+ state: "ringing",
4266
+ userID: body.conversationID,
4267
+ });
4268
+ return session;
4269
+ }
3875
4270
  setAlive(status) {
3876
4271
  this.isAlive = status;
3877
4272
  }
@@ -3904,6 +4299,66 @@ export class Client {
3904
4299
  async signPendingRegistrationChallenge(challengeHex) {
3905
4300
  return XUtils.encodeHex(await xSignAsync(XUtils.decodeHex(challengeHex), this.signKeys.secretKey));
3906
4301
  }
4302
+ async startEncryptedDmCall(recipientUserID, signal) {
4303
+ const { devices, user } = await this.fetchCallPeer({
4304
+ userID: recipientUserID,
4305
+ });
4306
+ const now = new Date();
4307
+ const createdAt = now.toISOString();
4308
+ const expiresAt = new Date(now.getTime() + CALL_INVITE_TTL_MS).toISOString();
4309
+ const session = {
4310
+ callID: uuid.v4(),
4311
+ conversationID: recipientUserID,
4312
+ conversationType: "dm",
4313
+ createdAt,
4314
+ createdBy: this.getUser().userID,
4315
+ createdByDeviceID: this.getDevice().deviceID,
4316
+ expiresAt,
4317
+ media: "audio",
4318
+ participants: [
4319
+ {
4320
+ acceptedAt: createdAt,
4321
+ deviceID: this.getDevice().deviceID,
4322
+ joinedAt: createdAt,
4323
+ state: "accepted",
4324
+ userID: this.getUser().userID,
4325
+ },
4326
+ {
4327
+ state: "ringing",
4328
+ userID: recipientUserID,
4329
+ },
4330
+ ],
4331
+ status: "ringing",
4332
+ };
4333
+ const state = {
4334
+ peerUserID: recipientUserID,
4335
+ pendingPeerDevices: devices,
4336
+ sequence: 1,
4337
+ session,
4338
+ };
4339
+ this.callStates.set(session.callID, state);
4340
+ const bodies = devices.map((device) => this.makeCallEnvelopeBody({
4341
+ action: "invite",
4342
+ expiresAt,
4343
+ sequence: state.sequence,
4344
+ signal,
4345
+ state,
4346
+ toDeviceID: device.deviceID,
4347
+ toUserID: recipientUserID,
4348
+ }));
4349
+ await this.deliverCallEnvelopeBatch({
4350
+ bodies,
4351
+ mailID: uuid.v4(),
4352
+ targetUser: user,
4353
+ });
4354
+ return {
4355
+ action: "invite",
4356
+ call: cloneCallSession(session),
4357
+ fromDeviceID: this.getDevice().deviceID,
4358
+ fromUserID: this.getUser().userID,
4359
+ ...(signal ? { signal } : {}),
4360
+ };
4361
+ }
3907
4362
  async submitOTK(amount) {
3908
4363
  const otks = [];
3909
4364
  for (let i = 0; i < amount; i++) {
@@ -4001,5 +4456,13 @@ export class Client {
4001
4456
  return null;
4002
4457
  }
4003
4458
  }
4459
+ upsertCallParticipant(session, patch) {
4460
+ const existing = session.participants.find((participant) => participant.userID === patch.userID);
4461
+ if (!existing) {
4462
+ session.participants.push({ ...patch });
4463
+ return;
4464
+ }
4465
+ Object.assign(existing, patch);
4466
+ }
4004
4467
  }
4005
4468
  //# sourceMappingURL=Client.js.map