@vex-chat/libvex 7.1.6 → 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 { 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";
@@ -199,8 +199,13 @@ import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, C
199
199
  import { sqlSessionToCrypto } from "./utils/sqlSessionToCrypto.js";
200
200
  import { uuidToUint8 } from "./utils/uint8uuid.js";
201
201
  const _protocolMsgRegex = /��\w+:\w+��/g;
202
+ const NotificationSubscriptionChannelSchema = z.enum([
203
+ "apnsVoip",
204
+ "expo",
205
+ "fcmCall",
206
+ ]);
202
207
  const NotificationSubscriptionSchema = z.object({
203
- channel: z.literal("expo"),
208
+ channel: NotificationSubscriptionChannelSchema,
204
209
  createdAt: z.string(),
205
210
  deviceID: z.string(),
206
211
  enabled: z.boolean(),
@@ -268,6 +273,9 @@ const messageSchema = z.object({
268
273
  sender: z.string(),
269
274
  timestamp: z.string(),
270
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;
271
279
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
272
280
  const MAIL_FANOUT_CONCURRENCY = 8;
273
281
  const MAIL_BATCH_MAX_SIZE = 32;
@@ -282,6 +290,52 @@ const mailBatchResponseSchema = z.object({
282
290
  status: z.number().int().optional(),
283
291
  })),
284
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
+ }
285
339
  function decodeMessageBlob(body) {
286
340
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
287
341
  return { message: body };
@@ -315,6 +369,9 @@ function decodeMessagePlaintext(plaintext) {
315
369
  }
316
370
  : blob;
317
371
  }
372
+ function encodeCallEnvelopePlaintext(envelope) {
373
+ return XUtils.decodeUTF8(CALL_ENVELOPE_PREFIX + JSON.stringify(envelope));
374
+ }
318
375
  function encodeMessagePlaintext(message, opts) {
319
376
  const body = opts?.extra === undefined
320
377
  ? message
@@ -325,6 +382,12 @@ function encodeMessagePlaintext(message, opts) {
325
382
  });
326
383
  return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
327
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
+ }
328
391
  function messageFromDecodedPlaintext(decoded) {
329
392
  return {
330
393
  ...(decoded.extra !== undefined ? { extra: decoded.extra } : {}),
@@ -334,6 +397,9 @@ function messageFromDecodedPlaintext(decoded) {
334
397
  : {}),
335
398
  };
336
399
  }
400
+ function normalizeCallEnvelopeBodyForWire(body) {
401
+ return CallEnvelopeBodySchema.parse(jsonWireValue(body));
402
+ }
337
403
  function normalizeForwardedMessage(message) {
338
404
  const decoded = decodeMessagePlaintext(message.message);
339
405
  return {
@@ -380,6 +446,23 @@ export class Client {
380
446
  static encryptKeyData = XUtils.encryptKeyData;
381
447
  static encryptKeyDataAsync = XUtils.encryptKeyDataAsync;
382
448
  static NOT_FOUND_TTL = 30 * 60 * 1000;
449
+ /**
450
+ * Voice-call signaling operations.
451
+ *
452
+ * Platform apps own native media capture/WebRTC. These methods only move
453
+ * authenticated signaling and call state over Spire.
454
+ */
455
+ calls = {
456
+ accept: (callID, signal) => this.sendEncryptedCallAction("accept", callID, signal),
457
+ active: this.fetchActiveCalls.bind(this),
458
+ cancel: (callID) => this.sendEncryptedCallAction("cancel", callID),
459
+ hangup: (callID) => this.sendEncryptedCallAction("hangup", callID),
460
+ ice: (callID, signal) => this.sendEncryptedCallAction("ice", callID, signal),
461
+ iceServers: this.fetchIceServers.bind(this),
462
+ reject: (callID) => this.sendEncryptedCallAction("reject", callID),
463
+ signal: (callID, signal) => this.sendEncryptedCallAction("signal", callID, signal),
464
+ startDM: (recipientUserID, signal) => this.startEncryptedDmCall(recipientUserID, signal),
465
+ };
383
466
  /**
384
467
  * Browser-safe NODE_ENV accessor.
385
468
  * Uses indirect lookup so the bare `process` global never appears in
@@ -664,6 +747,7 @@ export class Client {
664
747
  retrieve: this.fetchUser.bind(this),
665
748
  };
666
749
  autoReconnectEnabled = false;
750
+ callStates = new Map();
667
751
  cryptoProfile;
668
752
  database;
669
753
  dbPath;
@@ -1356,6 +1440,86 @@ export class Client {
1356
1440
  this.emitUndecryptedMessage(mail, timestamp);
1357
1441
  this.acknowledgeInboundMail(mail);
1358
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
+ }
1359
1523
  async approveDeviceRequest(requestID) {
1360
1524
  const req = await this.getDeviceRegistrationRequest(requestID);
1361
1525
  if (!req) {
@@ -1392,6 +1556,35 @@ export class Client {
1392
1556
  "/passkeys/register/begin", msgpack.encode({ name: args.name, signed }), { headers: { "Content-Type": "application/msgpack" } });
1393
1557
  return decodeHttpResponse(PasskeyOptionsCodec, response.data);
1394
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
+ }
1395
1588
  censorPreKey(preKey) {
1396
1589
  if (!preKey.index) {
1397
1590
  throw new Error("Key index is required.");
@@ -1494,7 +1687,7 @@ export class Client {
1494
1687
  * When `readMail` triggers a best-effort session re-establish, key-bundle
1495
1688
  * errors should not reject the full read pipeline.
1496
1689
  */
1497
- allowKeyBundleFailure = false) {
1690
+ allowKeyBundleFailure = false, notify) {
1498
1691
  return this.runWithThisCryptoProfile(async () => {
1499
1692
  let keyBundle;
1500
1693
  try {
@@ -1575,10 +1768,11 @@ export class Client {
1575
1768
  recipient: device.deviceID,
1576
1769
  sender: this.getDevice().deviceID,
1577
1770
  };
1771
+ const wireMail = notify ? { ...mail, notify } : mail;
1578
1772
  const hmac = xHMAC(mail, SK);
1579
1773
  const msg = {
1580
1774
  action: "CREATE",
1581
- data: mail,
1775
+ data: wireMail,
1582
1776
  resourceType: "mail",
1583
1777
  transmissionID: uuid.v4(),
1584
1778
  type: "resource",
@@ -1598,32 +1792,38 @@ export class Client {
1598
1792
  };
1599
1793
  await this.database.saveSession(sessionEntry);
1600
1794
  this.emitter.emit("session", sessionEntry, user);
1601
- // emit the message
1795
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
1796
+ const callEnvelope = forward
1797
+ ? null
1798
+ : decodeCallEnvelopePlaintext(rawPlaintext);
1602
1799
  const forwardedMsg = forward
1603
1800
  ? messageSchema.parse(msgpack.decode(message))
1604
1801
  : null;
1605
- const shouldEmitHandshakeMessage = forward || message.length > 0;
1606
1802
  const emitMsg = forwardedMsg
1607
1803
  ? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
1608
- : {
1609
- authorID: mail.authorID,
1610
- decrypted: true,
1611
- direction: "outgoing",
1612
- forward: mail.forward,
1613
- group: mail.group ? uuid.stringify(mail.group) : null,
1614
- mailID: mail.mailID,
1615
- ...messageFromDecodedPlaintext(decodeMessagePlaintext(XUtils.encodeUTF8(message))),
1616
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1617
- readerID: mail.readerID,
1618
- recipient: mail.recipient,
1619
- sender: mail.sender,
1620
- timestamp: new Date().toISOString(),
1621
- };
1622
- 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) {
1623
1823
  this.emitter.emit("message", emitMsg);
1624
1824
  }
1625
- await this.deliverMailResource(msg, hmac, mail);
1626
- return shouldEmitHandshakeMessage ? emitMsg : null;
1825
+ await this.deliverMailResource(msg, hmac, wireMail);
1826
+ return emitMsg;
1627
1827
  });
1628
1828
  }
1629
1829
  async deleteChannel(channelID) {
@@ -1653,6 +1853,49 @@ export class Client {
1653
1853
  async deleteServer(serverID) {
1654
1854
  await this.http.delete(this.getHost() + "/server/" + serverID);
1655
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
+ }
1656
1899
  deliverMailResource(msg, header, mail) {
1657
1900
  if (this.mailBatchUnsupported) {
1658
1901
  return this.deliverMailResourceOverSocket(msg, header);
@@ -1725,6 +1968,59 @@ export class Client {
1725
1968
  timestamp,
1726
1969
  });
1727
1970
  }
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 };
2015
+ }
2016
+ async fetchIceServers() {
2017
+ const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
2018
+ responseType: "json",
2019
+ });
2020
+ return z
2021
+ .object({ iceServers: z.array(IceServerConfigSchema) })
2022
+ .parse(res.data).iceServers;
2023
+ }
1728
2024
  /**
1729
2025
  * Gets a list of permissions for a server.
1730
2026
  *
@@ -1819,6 +2115,23 @@ export class Client {
1819
2115
  }
1820
2116
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1821
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
+ }
1822
2135
  /**
1823
2136
  * Finish a passkey login and adopt the resulting JWT as the
1824
2137
  * client's bearer token. After this call, `client.passkeys.*`
@@ -2197,6 +2510,22 @@ export class Client {
2197
2510
  }
2198
2511
  async handleNotify(msg) {
2199
2512
  switch (msg.event) {
2513
+ case "call":
2514
+ case "callInvite": {
2515
+ const parsed = CallEventSchema.safeParse(msg.data);
2516
+ if (parsed.success) {
2517
+ this.emitter.emit("call", parsed.data);
2518
+ }
2519
+ break;
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
+ }
2200
2529
  case "deviceRequest": {
2201
2530
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
2202
2531
  if (parsed.success) {
@@ -2414,6 +2743,67 @@ export class Client {
2414
2743
  const response = await this.http.get(this.getHost() + "/user/" + userID + "/passkeys");
2415
2744
  return decodeHttpResponse(PasskeyArrayCodec, response.data);
2416
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
+ }
2417
2807
  async markSessionVerified(sessionID) {
2418
2808
  return this.database.markSessionVerified(sessionID);
2419
2809
  }
@@ -2596,6 +2986,29 @@ export class Client {
2596
2986
  }
2597
2987
  }
2598
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
+ }
2599
3012
  async publishPendingDeviceRegistration(args) {
2600
3013
  const signed = await this.signPendingRegistrationChallenge(args.challenge);
2601
3014
  await this.http.post(this.getHost() +
@@ -2848,39 +3261,52 @@ export class Client {
2848
3261
  if (!mail.forward) {
2849
3262
  plaintext = XUtils.encodeUTF8(unsealed);
2850
3263
  }
2851
- const decodedPlaintext = mail.forward
3264
+ const callEnvelope = mail.forward
2852
3265
  ? null
2853
- : decodeMessagePlaintext(plaintext);
2854
- // emit the message
2855
- const fwdMsg1 = mail.forward
2856
- ? messageSchema.parse(msgpack.decode(unsealed))
2857
- : null;
2858
- const message = fwdMsg1
2859
- ? {
2860
- ...normalizeForwardedMessage(fwdMsg1),
2861
- 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);
2862
3309
  }
2863
- : {
2864
- authorID: mail.authorID,
2865
- decrypted: true,
2866
- direction: "incoming",
2867
- forward: mail.forward,
2868
- group: mail.group
2869
- ? uuid.stringify(mail.group)
2870
- : null,
2871
- mailID: mail.mailID,
2872
- ...messageFromDecodedPlaintext(decodedPlaintext ?? {
2873
- message: plaintext,
2874
- }),
2875
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2876
- readerID: mail.readerID,
2877
- recipient: mail.recipient,
2878
- sender: mail.sender,
2879
- timestamp: timestamp,
2880
- };
2881
- const shouldEmitIncomingInitial = mail.forward || plaintext.length > 0;
2882
- if (shouldEmitIncomingInitial) {
2883
- this.emitter.emit("message", message);
2884
3310
  }
2885
3311
  if (libvexDebugDmEnabled()) {
2886
3312
  try {
@@ -3046,33 +3472,47 @@ export class Client {
3046
3472
  ? messageSchema.parse(msgpack.decode(decrypted))
3047
3473
  : null;
3048
3474
  const rawIncoming = XUtils.encodeUTF8(decrypted);
3049
- const decodedPlaintext = mail.forward
3475
+ const callEnvelope = mail.forward
3050
3476
  ? null
3051
- : decodeMessagePlaintext(rawIncoming);
3052
- const message = fwdMsg2
3053
- ? {
3054
- ...normalizeForwardedMessage(fwdMsg2),
3055
- 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);
3056
3485
  }
3057
- : {
3058
- authorID: mail.authorID,
3059
- decrypted: true,
3060
- direction: "incoming",
3061
- forward: mail.forward,
3062
- group: mail.group
3063
- ? uuid.stringify(mail.group)
3064
- : null,
3065
- mailID: mail.mailID,
3066
- ...messageFromDecodedPlaintext(decodedPlaintext ?? {
3067
- message: rawIncoming,
3068
- }),
3069
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3070
- readerID: mail.readerID,
3071
- recipient: mail.recipient,
3072
- sender: mail.sender,
3073
- timestamp: timestamp,
3074
- };
3075
- 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
+ }
3076
3516
  const sqlPatch = sessionToSqlPatch(session);
3077
3517
  const persisted = {
3078
3518
  CKr: sqlPatch.CKr,
@@ -3410,6 +3850,45 @@ export class Client {
3410
3850
  throw err;
3411
3851
  }
3412
3852
  }
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({
3873
+ action,
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,
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);
3891
+ }
3413
3892
  async sendGroupMessage(channelID, message, opts) {
3414
3893
  const userList = await this.getUserList(channelID);
3415
3894
  for (const user of userList) {
@@ -3497,7 +3976,7 @@ export class Client {
3497
3976
  }
3498
3977
  }
3499
3978
  /* Sends encrypted mail to a user. */
3500
- async sendMail(device, user, msg, group, mailID, forward, retry = false) {
3979
+ async sendMail(device, user, msg, group, mailID, forward, retry = false, notify) {
3501
3980
  while (this.sending.has(device.deviceID)) {
3502
3981
  await sleep(100);
3503
3982
  }
@@ -3512,7 +3991,7 @@ export class Client {
3512
3991
  retry: String(retry),
3513
3992
  });
3514
3993
  }
3515
- 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);
3516
3995
  if (libvexDebugDmEnabled()) {
3517
3996
  debugLibvexDm("sendMail: createSession returned", {
3518
3997
  peerDevice: device.deviceID,
@@ -3551,34 +4030,43 @@ export class Client {
3551
4030
  recipient: device.deviceID,
3552
4031
  sender: this.getDevice().deviceID,
3553
4032
  };
4033
+ const wireMail = notify ? { ...mail, notify } : mail;
3554
4034
  const msgb = {
3555
4035
  action: "CREATE",
3556
- data: mail,
4036
+ data: wireMail,
3557
4037
  resourceType: "mail",
3558
4038
  transmissionID: uuid.v4(),
3559
4039
  type: "resource",
3560
4040
  };
3561
4041
  const hmac = xHMAC(mail, messageKey);
4042
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
4043
+ const callEnvelope = forward
4044
+ ? null
4045
+ : decodeCallEnvelopePlaintext(rawPlaintext);
3562
4046
  const fwdOut = forward
3563
4047
  ? messageSchema.parse(msgpack.decode(msg))
3564
4048
  : null;
3565
4049
  const outMsg = fwdOut
3566
4050
  ? { ...normalizeForwardedMessage(fwdOut), forward: true }
3567
- : {
3568
- authorID: mail.authorID,
3569
- decrypted: true,
3570
- direction: "outgoing",
3571
- forward: mail.forward,
3572
- group: mail.group ? uuid.stringify(mail.group) : null,
3573
- mailID: mail.mailID,
3574
- ...messageFromDecodedPlaintext(decodeMessagePlaintext(XUtils.encodeUTF8(msg))),
3575
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3576
- readerID: mail.readerID,
3577
- recipient: mail.recipient,
3578
- sender: mail.sender,
3579
- timestamp: new Date().toISOString(),
3580
- };
3581
- 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
+ }
3582
4070
  const sqlPatch = sessionToSqlPatch(session);
3583
4071
  const persisted = {
3584
4072
  CKr: sqlPatch.CKr,
@@ -3603,22 +4091,22 @@ export class Client {
3603
4091
  };
3604
4092
  await this.database.saveSession(persisted);
3605
4093
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
3606
- await this.deliverMailResource(msgb, hmac, mail);
4094
+ await this.deliverMailResource(msgb, hmac, wireMail);
3607
4095
  return outMsg;
3608
4096
  }
3609
4097
  finally {
3610
4098
  this.sending.delete(device.deviceID);
3611
4099
  }
3612
4100
  }
3613
- async sendMailWithRecovery(device, user, msg, group, mailID, forward, forceFreshSession = false) {
4101
+ async sendMailWithRecovery(device, user, msg, group, mailID, forward, forceFreshSession = false, notify) {
3614
4102
  try {
3615
- return await this.sendMail(device, user, msg, group, mailID, forward, forceFreshSession);
4103
+ return await this.sendMail(device, user, msg, group, mailID, forward, forceFreshSession, notify);
3616
4104
  }
3617
4105
  catch (err) {
3618
4106
  if (!this.shouldRetryDeliveryWithFreshSession(err)) {
3619
4107
  throw err;
3620
4108
  }
3621
- return await this.sendMail(device, user, msg, group, mailID, forward, true);
4109
+ return await this.sendMail(device, user, msg, group, mailID, forward, true, notify);
3622
4110
  }
3623
4111
  }
3624
4112
  async sendMessage(userID, message, opts) {
@@ -3753,6 +4241,32 @@ export class Client {
3753
4241
  // Don't surface a teardown race as an unhandled rejection.
3754
4242
  this.send(receipt).catch(ignoreSocketTeardown);
3755
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
+ }
3756
4270
  setAlive(status) {
3757
4271
  this.isAlive = status;
3758
4272
  }
@@ -3785,6 +4299,66 @@ export class Client {
3785
4299
  async signPendingRegistrationChallenge(challengeHex) {
3786
4300
  return XUtils.encodeHex(await xSignAsync(XUtils.decodeHex(challengeHex), this.signKeys.secretKey));
3787
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
+ }
3788
4362
  async submitOTK(amount) {
3789
4363
  const otks = [];
3790
4364
  for (let i = 0; i < amount; i++) {
@@ -3882,5 +4456,13 @@ export class Client {
3882
4456
  return null;
3883
4457
  }
3884
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
+ }
3885
4467
  }
3886
4468
  //# sourceMappingURL=Client.js.map