@vex-chat/libvex 7.1.5 → 7.2.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/src/Client.ts CHANGED
@@ -15,6 +15,10 @@ import type {
15
15
  import type { KeyPair } from "@vex-chat/crypto";
16
16
  import type {
17
17
  ActionToken,
18
+ CallEvent,
19
+ CallResourceData,
20
+ CallSession,
21
+ CallSignalPayload,
18
22
  ChallMsg,
19
23
  Channel,
20
24
  Device,
@@ -22,6 +26,7 @@ import type {
22
26
  Emoji,
23
27
  FileResponse,
24
28
  FileSQL,
29
+ IceServerConfig,
25
30
  Invite,
26
31
  KeyBundle,
27
32
  MailWS,
@@ -70,6 +75,9 @@ import {
70
75
  XUtils,
71
76
  } from "@vex-chat/crypto";
72
77
  import {
78
+ CallEventSchema,
79
+ CallSessionSchema,
80
+ IceServerConfigSchema,
73
81
  MailType,
74
82
  MailWSSchema,
75
83
  PermissionSchema,
@@ -190,6 +198,16 @@ function debugLibvexDm(
190
198
  console.error(`[libvex:debug-dm] ${payload}`);
191
199
  }
192
200
 
201
+ function errorFromUnknown(err: unknown): Error {
202
+ if (err instanceof Error) {
203
+ return err;
204
+ }
205
+ if (typeof err === "string") {
206
+ return new Error(err);
207
+ }
208
+ return new Error(JSON.stringify(err));
209
+ }
210
+
193
211
  function ignoreSocketTeardown(err: unknown): void {
194
212
  if (err instanceof WebSocketNotOpenError) return;
195
213
  // Re-throw anything else as a real unhandled rejection so it
@@ -356,6 +374,28 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
356
374
 
357
375
  const _protocolMsgRegex = /��\w+:\w+��/g;
358
376
 
377
+ /**
378
+ * Voice-call signaling operations.
379
+ *
380
+ * `libvex` moves authenticated call control over Spire. Platform apps own
381
+ * WebRTC/media capture and pass offers, answers, and ICE candidates through
382
+ * these methods.
383
+ */
384
+ export interface Calls {
385
+ accept: (callID: string, signal?: CallSignalPayload) => Promise<CallEvent>;
386
+ active: () => Promise<CallSession[]>;
387
+ cancel: (callID: string) => Promise<CallEvent>;
388
+ hangup: (callID: string) => Promise<CallEvent>;
389
+ ice: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
390
+ iceServers: () => Promise<IceServerConfig[]>;
391
+ reject: (callID: string) => Promise<CallEvent>;
392
+ signal: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
393
+ startDM: (
394
+ recipientUserID: string,
395
+ signal?: CallSignalPayload,
396
+ ) => Promise<CallEvent>;
397
+ }
398
+
359
399
  /**
360
400
  * @ignore
361
401
  */
@@ -1034,6 +1074,8 @@ const retryRequestNotifyData = z.union([
1034
1074
  * and {@link Client.once}.
1035
1075
  */
1036
1076
  export interface ClientEvents {
1077
+ /** Voice-call signaling changed or an incoming call was received. */
1078
+ call: (event: CallEvent) => void;
1037
1079
  /** The client has been shut down (via {@link Client.close}). */
1038
1080
  closed: () => void;
1039
1081
  /** WebSocket authorized by the server; pre-auth setup begins. */
@@ -1280,6 +1322,35 @@ export class Client {
1280
1322
 
1281
1323
  private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
1282
1324
 
1325
+ /**
1326
+ * Voice-call signaling operations.
1327
+ *
1328
+ * Platform apps own native media capture/WebRTC. These methods only move
1329
+ * authenticated signaling and call state over Spire.
1330
+ */
1331
+ public calls: Calls = {
1332
+ accept: (callID: string, signal?: CallSignalPayload) =>
1333
+ this.sendCallResource("ACCEPT", {
1334
+ callID,
1335
+ ...(signal ? { signal } : {}),
1336
+ }),
1337
+ active: this.fetchActiveCalls.bind(this),
1338
+ cancel: (callID: string) => this.sendCallResource("CANCEL", { callID }),
1339
+ hangup: (callID: string) => this.sendCallResource("HANGUP", { callID }),
1340
+ ice: (callID: string, signal: CallSignalPayload) =>
1341
+ this.sendCallResource("ICE", { callID, signal }),
1342
+ iceServers: this.fetchIceServers.bind(this),
1343
+ reject: (callID: string) => this.sendCallResource("REJECT", { callID }),
1344
+ signal: (callID: string, signal: CallSignalPayload) =>
1345
+ this.sendCallResource("SIGNAL", { callID, signal }),
1346
+ startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
1347
+ this.sendCallResource("INVITE", {
1348
+ conversationType: "dm",
1349
+ recipientUserID,
1350
+ ...(signal ? { signal } : {}),
1351
+ }),
1352
+ };
1353
+
1283
1354
  /**
1284
1355
  * Browser-safe NODE_ENV accessor.
1285
1356
  * Uses indirect lookup so the bare `process` global never appears in
@@ -2488,6 +2559,7 @@ export class Client {
2488
2559
  private acknowledgeRepeatedDecryptFailure(
2489
2560
  mail: MailWS,
2490
2561
  count: number,
2562
+ timestamp: string,
2491
2563
  ): void {
2492
2564
  if (count < 2) return;
2493
2565
  if (libvexDebugDmEnabled()) {
@@ -2498,6 +2570,7 @@ export class Client {
2498
2570
  thisDevice: this.getDevice().deviceID,
2499
2571
  });
2500
2572
  }
2573
+ this.emitUndecryptedMessage(mail, timestamp);
2501
2574
  this.acknowledgeInboundMail(mail);
2502
2575
  }
2503
2576
 
@@ -2978,6 +3051,7 @@ export class Client {
2978
3051
  }
2979
3052
  });
2980
3053
  }
3054
+
2981
3055
  private async deliverMailResourceOverSocket(
2982
3056
  msg: ResourceMsg,
2983
3057
  header: Uint8Array,
@@ -3007,6 +3081,7 @@ export class Client {
3007
3081
  });
3008
3082
  });
3009
3083
  }
3084
+
3010
3085
  private deviceListFailureDetail(err: unknown): string {
3011
3086
  if (!isHttpError(err)) {
3012
3087
  return "";
@@ -3021,6 +3096,39 @@ export class Client {
3021
3096
  return "";
3022
3097
  }
3023
3098
 
3099
+ private emitUndecryptedMessage(mail: MailWS, timestamp: string): void {
3100
+ this.emitter.emit("message", {
3101
+ authorID: mail.authorID,
3102
+ decrypted: false,
3103
+ direction: "incoming",
3104
+ forward: mail.forward,
3105
+ group: mail.group ? uuid.stringify(mail.group) : null,
3106
+ mailID: mail.mailID,
3107
+ message: "",
3108
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3109
+ readerID: mail.readerID,
3110
+ recipient: mail.recipient,
3111
+ sender: mail.sender,
3112
+ timestamp,
3113
+ });
3114
+ }
3115
+
3116
+ private async fetchActiveCalls(): Promise<CallSession[]> {
3117
+ const res = await this.http.get(this.getHost() + "/calls/active", {
3118
+ responseType: "json",
3119
+ });
3120
+ return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
3121
+ .calls;
3122
+ }
3123
+ private async fetchIceServers(): Promise<IceServerConfig[]> {
3124
+ const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3125
+ responseType: "json",
3126
+ });
3127
+ return z
3128
+ .object({ iceServers: z.array(IceServerConfigSchema) })
3129
+ .parse(res.data).iceServers;
3130
+ }
3131
+
3024
3132
  /**
3025
3133
  * Gets a list of permissions for a server.
3026
3134
  *
@@ -3661,6 +3769,14 @@ export class Client {
3661
3769
 
3662
3770
  private async handleNotify(msg: NotifyMsg) {
3663
3771
  switch (msg.event) {
3772
+ case "call":
3773
+ case "callInvite": {
3774
+ const parsed = CallEventSchema.safeParse(msg.data);
3775
+ if (parsed.success) {
3776
+ this.emitter.emit("call", parsed.data);
3777
+ }
3778
+ break;
3779
+ }
3664
3780
  case "deviceRequest": {
3665
3781
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
3666
3782
  if (parsed.success) {
@@ -4322,11 +4438,14 @@ export class Client {
4322
4438
  );
4323
4439
 
4324
4440
  if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
4441
+ const failureCount =
4442
+ this.registerDecryptFailure(mail);
4325
4443
  if (libvexDebugDmEnabled()) {
4326
4444
  try {
4327
4445
  debugLibvexDm(
4328
4446
  "readMail initial: abort (otk index mismatch)",
4329
4447
  {
4448
+ attempts: failureCount,
4330
4449
  mailID: mail.mailID,
4331
4450
  otkIndex: String(
4332
4451
  otk?.index ?? "null",
@@ -4345,6 +4464,17 @@ export class Client {
4345
4464
  );
4346
4465
  }
4347
4466
  }
4467
+ if (failureCount === 1) {
4468
+ this.emitter.emit("retryRequest", {
4469
+ mailID: mail.mailID,
4470
+ source: "decrypt_failure",
4471
+ });
4472
+ }
4473
+ this.acknowledgeRepeatedDecryptFailure(
4474
+ mail,
4475
+ failureCount,
4476
+ timestamp,
4477
+ );
4348
4478
  return;
4349
4479
  }
4350
4480
 
@@ -4363,11 +4493,14 @@ export class Client {
4363
4493
  return c;
4364
4494
  })();
4365
4495
  if (!IK_A) {
4496
+ const failureCount =
4497
+ this.registerDecryptFailure(mail);
4366
4498
  if (libvexDebugDmEnabled()) {
4367
4499
  try {
4368
4500
  debugLibvexDm(
4369
4501
  "readMail initial: abort (IK_A null, Ed→X25519?)",
4370
4502
  {
4503
+ attempts: failureCount,
4371
4504
  fips: String(fipsRead),
4372
4505
  mailID: mail.mailID,
4373
4506
  thisDevice:
@@ -4383,6 +4516,17 @@ export class Client {
4383
4516
  );
4384
4517
  }
4385
4518
  }
4519
+ if (failureCount === 1) {
4520
+ this.emitter.emit("retryRequest", {
4521
+ mailID: mail.mailID,
4522
+ source: "decrypt_failure",
4523
+ });
4524
+ }
4525
+ this.acknowledgeRepeatedDecryptFailure(
4526
+ mail,
4527
+ failureCount,
4528
+ timestamp,
4529
+ );
4386
4530
  return;
4387
4531
  }
4388
4532
  const EK_A = ephKey;
@@ -4431,11 +4575,14 @@ export class Client {
4431
4575
  );
4432
4576
 
4433
4577
  if (!XUtils.bytesEqual(hmac, header)) {
4578
+ const failureCount =
4579
+ this.registerDecryptFailure(mail);
4434
4580
  if (libvexDebugDmEnabled()) {
4435
4581
  try {
4436
4582
  debugLibvexDm(
4437
4583
  "readMail initial: abort (HMAC mismatch)",
4438
4584
  {
4585
+ attempts: failureCount,
4439
4586
  mailID: mail.mailID,
4440
4587
  preKeyIndex: String(preKeyIndex),
4441
4588
  thisDevice:
@@ -4451,6 +4598,17 @@ export class Client {
4451
4598
  );
4452
4599
  }
4453
4600
  }
4601
+ if (failureCount === 1) {
4602
+ this.emitter.emit("retryRequest", {
4603
+ mailID: mail.mailID,
4604
+ source: "decrypt_failure",
4605
+ });
4606
+ }
4607
+ this.acknowledgeRepeatedDecryptFailure(
4608
+ mail,
4609
+ failureCount,
4610
+ timestamp,
4611
+ );
4454
4612
  return;
4455
4613
  }
4456
4614
  const unsealed = await xSecretboxOpenAsync(
@@ -4582,15 +4740,29 @@ export class Client {
4582
4740
  }
4583
4741
  this.acknowledgeInboundMail(mail);
4584
4742
  } else {
4743
+ const failureCount =
4744
+ this.registerDecryptFailure(mail);
4585
4745
  if (libvexDebugDmEnabled()) {
4586
4746
  debugLibvexDm(
4587
4747
  "readMail initial: abort (xSecretboxOpen null)",
4588
4748
  {
4749
+ attempts: failureCount,
4589
4750
  mailID: mail.mailID,
4590
4751
  preKeyIndex: String(preKeyIndex),
4591
4752
  },
4592
4753
  );
4593
4754
  }
4755
+ if (failureCount === 1) {
4756
+ this.emitter.emit("retryRequest", {
4757
+ mailID: mail.mailID,
4758
+ source: "decrypt_failure",
4759
+ });
4760
+ }
4761
+ this.acknowledgeRepeatedDecryptFailure(
4762
+ mail,
4763
+ failureCount,
4764
+ timestamp,
4765
+ );
4594
4766
  }
4595
4767
  break;
4596
4768
  case MailType.subsequent: {
@@ -4613,7 +4785,20 @@ export class Client {
4613
4785
  }
4614
4786
 
4615
4787
  if (!session) {
4788
+ const failureCount =
4789
+ this.registerDecryptFailure(mail);
4616
4790
  healSession();
4791
+ if (failureCount === 1) {
4792
+ this.emitter.emit("retryRequest", {
4793
+ mailID: mail.mailID,
4794
+ source: "decrypt_failure",
4795
+ });
4796
+ }
4797
+ this.acknowledgeRepeatedDecryptFailure(
4798
+ mail,
4799
+ failureCount,
4800
+ timestamp,
4801
+ );
4617
4802
  return;
4618
4803
  }
4619
4804
 
@@ -4717,6 +4902,7 @@ export class Client {
4717
4902
  this.acknowledgeRepeatedDecryptFailure(
4718
4903
  mail,
4719
4904
  failureCount,
4905
+ timestamp,
4720
4906
  );
4721
4907
  return;
4722
4908
  }
@@ -4808,6 +4994,7 @@ export class Client {
4808
4994
  this.acknowledgeRepeatedDecryptFailure(
4809
4995
  mail,
4810
4996
  failureCount,
4997
+ timestamp,
4811
4998
  );
4812
4999
  }
4813
5000
  break;
@@ -5179,6 +5366,89 @@ export class Client {
5179
5366
  }
5180
5367
  }
5181
5368
 
5369
+ private async sendCallResource(
5370
+ action: string,
5371
+ data: CallResourceData,
5372
+ ): Promise<CallEvent> {
5373
+ const msg: ResourceMsg = {
5374
+ action,
5375
+ data,
5376
+ resourceType: "call",
5377
+ transmissionID: uuid.v4(),
5378
+ type: "resource",
5379
+ };
5380
+
5381
+ return await new Promise<CallEvent>((resolve, reject) => {
5382
+ const settle = (err: null | unknown, event?: CallEvent) => {
5383
+ this.socket.off("message", callback);
5384
+ if (err !== null) {
5385
+ reject(errorFromUnknown(err));
5386
+ return;
5387
+ }
5388
+ if (!event) {
5389
+ reject(new Error("Call signaling response was empty."));
5390
+ return;
5391
+ }
5392
+ resolve(event);
5393
+ };
5394
+
5395
+ const callback = (packedMsg: Uint8Array) => {
5396
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
5397
+ if (receivedMsg.transmissionID !== msg.transmissionID) {
5398
+ return;
5399
+ }
5400
+
5401
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
5402
+ if (!parsed.success) {
5403
+ settle(
5404
+ "Call signaling failed: " + JSON.stringify(receivedMsg),
5405
+ );
5406
+ return;
5407
+ }
5408
+
5409
+ if (parsed.data.type === "success") {
5410
+ const event = CallEventSchema.safeParse(parsed.data.data);
5411
+ if (!event.success) {
5412
+ settle(
5413
+ "Invalid call signaling response: " +
5414
+ JSON.stringify(event.error.issues),
5415
+ );
5416
+ return;
5417
+ }
5418
+ settle(null, event.data);
5419
+ return;
5420
+ }
5421
+
5422
+ if (parsed.data.type === "error") {
5423
+ settle(new Error(parsed.data.error));
5424
+ return;
5425
+ }
5426
+
5427
+ if (
5428
+ parsed.data.type === "notify" &&
5429
+ (parsed.data.event === "call" ||
5430
+ parsed.data.event === "callInvite")
5431
+ ) {
5432
+ const event = CallEventSchema.safeParse(parsed.data.data);
5433
+ if (event.success) {
5434
+ settle(null, event.data);
5435
+ }
5436
+ return;
5437
+ }
5438
+
5439
+ settle(
5440
+ "Unexpected call signaling response: " +
5441
+ JSON.stringify(parsed.data),
5442
+ );
5443
+ };
5444
+
5445
+ this.socket.on("message", callback);
5446
+ this.send(msg).catch((err: unknown) => {
5447
+ settle(err);
5448
+ });
5449
+ });
5450
+ }
5451
+
5182
5452
  private async sendGroupMessage(
5183
5453
  channelID: string,
5184
5454
  message: string,
@@ -0,0 +1,122 @@
1
+ import type { Message } from "../index.js";
2
+ import type { ClientDatabase } from "../storage/schema.js";
3
+
4
+ import BetterSqlite3 from "better-sqlite3";
5
+ import { Kysely, SqliteDialect } from "kysely";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { SqliteStorage } from "../storage/sqlite.js";
9
+
10
+ describe("SqliteStorage message at-rest encryption", () => {
11
+ it("stores generated decrypt-failure placeholders as the only plaintext exception", async () => {
12
+ const { db, storage } = makeStorage();
13
+ try {
14
+ await storage.init();
15
+
16
+ const placeholder = makeMessage({
17
+ decrypted: false,
18
+ mailID: "placeholder-mail",
19
+ message: "",
20
+ nonce: nonceHex(1),
21
+ });
22
+ await storage.saveMessage(placeholder);
23
+
24
+ const row = await messageRow(db, placeholder.mailID);
25
+ expect(row?.decrypted).toBe(0);
26
+ expect(row?.extra).toBeNull();
27
+ expect(row?.message).toBe("");
28
+
29
+ const history = await storage.getMessageHistory(
30
+ placeholder.authorID,
31
+ );
32
+ expect(history).toMatchObject([
33
+ {
34
+ decrypted: false,
35
+ mailID: placeholder.mailID,
36
+ message: "",
37
+ },
38
+ ]);
39
+ } finally {
40
+ await storage.close();
41
+ }
42
+ });
43
+
44
+ it("seals non-placeholder undecrypted messages and round-trips their content", async () => {
45
+ const { db, storage } = makeStorage();
46
+ try {
47
+ await storage.init();
48
+
49
+ const imported = makeMessage({
50
+ decrypted: false,
51
+ extra: JSON.stringify({ imported: true }),
52
+ mailID: "imported-mail",
53
+ message: "cleartext should not sit in sqlite",
54
+ nonce: nonceHex(2),
55
+ });
56
+ await storage.saveMessage(imported);
57
+
58
+ const row = await messageRow(db, imported.mailID);
59
+ expect(row?.decrypted).toBe(0);
60
+ expect(row?.extra).toBeNull();
61
+ expect(row?.message).not.toContain(imported.message);
62
+ expect(row?.message).not.toContain(imported.extra ?? "");
63
+
64
+ const history = await storage.getMessageHistory(imported.authorID);
65
+ expect(history).toMatchObject([
66
+ {
67
+ decrypted: false,
68
+ extra: imported.extra,
69
+ mailID: imported.mailID,
70
+ message: imported.message,
71
+ },
72
+ ]);
73
+ } finally {
74
+ await storage.close();
75
+ }
76
+ });
77
+ });
78
+
79
+ function makeMessage(overrides: Partial<Message>): Message {
80
+ return {
81
+ authorID: "peer-user",
82
+ decrypted: true,
83
+ direction: "incoming",
84
+ forward: false,
85
+ group: null,
86
+ mailID: "mail",
87
+ message: "hello",
88
+ nonce: nonceHex(0),
89
+ readerID: "local-user",
90
+ recipient: "local-device",
91
+ sender: "peer-device",
92
+ timestamp: "2026-06-01T00:00:00.000Z",
93
+ ...overrides,
94
+ };
95
+ }
96
+
97
+ function makeStorage(): {
98
+ db: Kysely<ClientDatabase>;
99
+ storage: SqliteStorage;
100
+ } {
101
+ const db = new Kysely<ClientDatabase>({
102
+ dialect: new SqliteDialect({
103
+ database: new BetterSqlite3(":memory:"),
104
+ }),
105
+ });
106
+ return {
107
+ db,
108
+ storage: new SqliteStorage(db, new Uint8Array(32).fill(7)),
109
+ };
110
+ }
111
+
112
+ async function messageRow(db: Kysely<ClientDatabase>, mailID: string) {
113
+ return await db
114
+ .selectFrom("messages")
115
+ .select(["decrypted", "extra", "message"])
116
+ .where("mailID", "=", mailID)
117
+ .executeTakeFirst();
118
+ }
119
+
120
+ function nonceHex(value: number): string {
121
+ return value.toString(16).padStart(48, "0");
122
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  export { Client, DeviceApprovalRequiredError } from "./Client.js";
8
8
  export type {
9
+ Calls,
9
10
  Channel,
10
11
  Channels,
11
12
  ClientEvents,
@@ -96,4 +97,13 @@ export type {
96
97
  UnsavedPreKey,
97
98
  } from "./types/index.js";
98
99
  // Re-export app-facing types
99
- export type { Invite, Passkey } from "@vex-chat/types";
100
+ export type {
101
+ CallAction,
102
+ CallEvent,
103
+ CallParticipant,
104
+ CallSession,
105
+ CallSignalPayload,
106
+ IceServerConfig,
107
+ Invite,
108
+ Passkey,
109
+ } from "@vex-chat/types";
@@ -538,24 +538,30 @@ export class SqliteStorage extends EventEmitter implements Storage {
538
538
  return;
539
539
  }
540
540
 
541
- // Encrypt plaintext with at-rest key before saving to disk.
542
541
  const storedPlaintext = encodeStoredMessagePlaintext(message);
542
+ const isPlaintextFailurePlaceholder =
543
+ !message.decrypted &&
544
+ message.message === "" &&
545
+ message.extra === undefined;
543
546
  const fips = getCryptoProfile() === "fips";
544
- const ct = fips
545
- ? await xSecretboxAsync(
546
- XUtils.decodeUTF8(storedPlaintext),
547
- XUtils.decodeHex(message.nonce),
548
- this.atRestAesKey,
549
- )
550
- : xSecretbox(
551
- XUtils.decodeUTF8(storedPlaintext),
552
- XUtils.decodeHex(message.nonce),
553
- this.atRestAesKey,
547
+ const encryptedMessage = isPlaintextFailurePlaceholder
548
+ ? storedPlaintext
549
+ : XUtils.encodeHex(
550
+ fips
551
+ ? await xSecretboxAsync(
552
+ XUtils.decodeUTF8(storedPlaintext),
553
+ XUtils.decodeHex(message.nonce),
554
+ this.atRestAesKey,
555
+ )
556
+ : xSecretbox(
557
+ XUtils.decodeUTF8(storedPlaintext),
558
+ XUtils.decodeHex(message.nonce),
559
+ this.atRestAesKey,
560
+ ),
554
561
  );
555
562
  if (this.isClosingNow()) {
556
563
  return;
557
564
  }
558
- const encryptedMessage = XUtils.encodeHex(ct);
559
565
 
560
566
  try {
561
567
  await this.db
@@ -787,7 +793,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
787
793
  for (const msg of messages) {
788
794
  const decryptedFlag = msg.decrypted !== 0;
789
795
  let plaintext = msg.message;
790
- if (decryptedFlag) {
796
+ const isPlaintextFailurePlaceholder =
797
+ !decryptedFlag && msg.message === "" && msg.extra === null;
798
+ if (!isPlaintextFailurePlaceholder) {
791
799
  const cipher = XUtils.decodeHex(msg.message);
792
800
  const nonce = XUtils.decodeHex(msg.nonce);
793
801
  const decrypted = fips