@voidly/agent-sdk 3.3.0 → 3.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -364,7 +364,11 @@ declare class VoidlyAgent {
364
364
  messageType?: string;
365
365
  unreadOnly?: boolean;
366
366
  }): Promise<DecryptedMessage[]>;
367
- /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
367
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports)
368
+ * Returns decrypted messages AND IDs of messages that failed to decrypt.
369
+ * Failed messages should be marked as read on the relay — they are permanently
370
+ * undecryptable and will poison the queue if left unread (Signal-style handling).
371
+ */
368
372
  private _decryptMessages;
369
373
  /**
370
374
  * Delete a message by ID (must be sender or recipient).
package/dist/index.js CHANGED
@@ -4220,6 +4220,7 @@ var VoidlyAgent = class _VoidlyAgent {
4220
4220
  } catch {
4221
4221
  }
4222
4222
  }
4223
+ const effectiveConfig = !mlkemSk && config?.postQuantum !== false ? { ...config, postQuantum: false } : config;
4223
4224
  const agent = new _VoidlyAgent({
4224
4225
  did: creds.did,
4225
4226
  apiKey: creds.apiKey,
@@ -4230,7 +4231,7 @@ var VoidlyAgent = class _VoidlyAgent {
4230
4231
  },
4231
4232
  mlkemPublicKey: mlkemPk,
4232
4233
  mlkemSecretKey: mlkemSk
4233
- }, config);
4234
+ }, effectiveConfig);
4234
4235
  if (creds.ratchetStates) {
4235
4236
  for (const [pairId, rs] of Object.entries(creds.ratchetStates)) {
4236
4237
  try {
@@ -4784,16 +4785,37 @@ var VoidlyAgent = class _VoidlyAgent {
4784
4785
  throw new Error(`Receive failed: ${err.error?.message || err.error || res.statusText}`);
4785
4786
  }
4786
4787
  const data = await res.json();
4787
- return this._decryptMessages(data.messages);
4788
+ const { decrypted, failedIds } = await this._decryptMessages(data.messages);
4789
+ if (failedIds.length > 0) {
4790
+ try {
4791
+ await this.markReadBatch(failedIds);
4792
+ } catch {
4793
+ for (const id of failedIds) {
4794
+ try {
4795
+ await this.markRead(id);
4796
+ } catch {
4797
+ }
4798
+ }
4799
+ }
4800
+ }
4801
+ return decrypted;
4788
4802
  }
4789
- /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4803
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports)
4804
+ * Returns decrypted messages AND IDs of messages that failed to decrypt.
4805
+ * Failed messages should be marked as read on the relay — they are permanently
4806
+ * undecryptable and will poison the queue if left unread (Signal-style handling).
4807
+ */
4790
4808
  async _decryptMessages(rawMessages) {
4791
4809
  const decrypted = [];
4810
+ const failedIds = [];
4792
4811
  const resetPeers = /* @__PURE__ */ new Set();
4793
4812
  for (const msg of rawMessages) {
4794
4813
  try {
4795
4814
  if (this._seenMessageIds.has(msg.id)) continue;
4796
- if (resetPeers.has(msg.from)) continue;
4815
+ if (resetPeers.has(msg.from)) {
4816
+ failedIds.push(msg.id);
4817
+ continue;
4818
+ }
4797
4819
  let senderEncPub;
4798
4820
  let senderSignPubBytes = null;
4799
4821
  if (msg.sender_encryption_key) {
@@ -4929,6 +4951,7 @@ var VoidlyAgent = class _VoidlyAgent {
4929
4951
  const skip = targetStep - state.recvStep;
4930
4952
  if (skip > MAX_SKIP) {
4931
4953
  this._decryptFailCount++;
4954
+ failedIds.push(msg.id);
4932
4955
  const peerForSkip = msg.from || "unknown";
4933
4956
  const prevSkipFails = this._peerDecryptFails.get(peerForSkip) || 0;
4934
4957
  this._peerDecryptFails.set(peerForSkip, prevSkipFails + 1);
@@ -4956,6 +4979,55 @@ var VoidlyAgent = class _VoidlyAgent {
4956
4979
  rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, messageKey);
4957
4980
  }
4958
4981
  }
4982
+ if (!rawPlaintext && envelopeDhRatchetKey && this.doubleRatchet && state) {
4983
+ try {
4984
+ const x25519Shared2 = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4985
+ let initialKey2;
4986
+ if (envelopePqCiphertext && this.mlkemSecretKey) {
4987
+ try {
4988
+ const pqCt2 = (0, import_tweetnacl_util.decodeBase64)(envelopePqCiphertext);
4989
+ const kem2 = new MlKem768();
4990
+ const pqShared2 = await kem2.decap(pqCt2, this.mlkemSecretKey);
4991
+ const combined2 = new Uint8Array(x25519Shared2.length + pqShared2.length);
4992
+ combined2.set(x25519Shared2, 0);
4993
+ combined2.set(pqShared2, x25519Shared2.length);
4994
+ initialKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined2));
4995
+ } catch {
4996
+ initialKey2 = x25519Shared2;
4997
+ }
4998
+ } else {
4999
+ initialKey2 = x25519Shared2;
5000
+ }
5001
+ const senderDhPub2 = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
5002
+ const dhOutput2 = import_tweetnacl.default.box.before(senderDhPub2, this.encryptionKeyPair.secretKey);
5003
+ const { newRootKey: rk2, newChainKey: ck2 } = await kdfRK(initialKey2, dhOutput2);
5004
+ let freshCk = ck2;
5005
+ for (let fi = 1; fi < envelopeRatchetStep; fi++) {
5006
+ const { nextChainKey: nck } = await ratchetStep(freshCk);
5007
+ freshCk = nck;
5008
+ }
5009
+ const { nextChainKey: finalCk, messageKey: freshMk } = await ratchetStep(freshCk);
5010
+ const freshPlain = import_tweetnacl.default.secretbox.open(ciphertext, nonce, freshMk);
5011
+ if (freshPlain) {
5012
+ rawPlaintext = freshPlain;
5013
+ state.recvChainKey = finalCk;
5014
+ state.recvStep = envelopeRatchetStep;
5015
+ state.rootKey = rk2;
5016
+ state.dhRecvPubKey = senderDhPub2;
5017
+ state.prevSendStep = state.sendStep;
5018
+ state.dhSendKeyPair = import_tweetnacl.default.box.keyPair();
5019
+ state.sendStep = 0;
5020
+ state.sendChainKey = initialKey2;
5021
+ const dhOut3 = import_tweetnacl.default.box.before(senderDhPub2, state.dhSendKeyPair.secretKey);
5022
+ const kdf3 = await kdfRK(state.rootKey, dhOut3);
5023
+ state.rootKey = kdf3.newRootKey;
5024
+ state.sendChainKey = kdf3.newChainKey;
5025
+ if (state.dhSkippedKeys) state.dhSkippedKeys.clear();
5026
+ if (state.skippedKeys) state.skippedKeys.clear();
5027
+ }
5028
+ } catch {
5029
+ }
5030
+ }
4959
5031
  if (!rawPlaintext) {
4960
5032
  rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
4961
5033
  }
@@ -4964,6 +5036,7 @@ var VoidlyAgent = class _VoidlyAgent {
4964
5036
  }
4965
5037
  if (!rawPlaintext) {
4966
5038
  this._decryptFailCount++;
5039
+ failedIds.push(msg.id);
4967
5040
  const senderForFail = msg.from || "unknown";
4968
5041
  const prevFails = this._peerDecryptFails.get(senderForFail) || 0;
4969
5042
  this._peerDecryptFails.set(senderForFail, prevFails + 1);
@@ -5044,6 +5117,7 @@ var VoidlyAgent = class _VoidlyAgent {
5044
5117
  }
5045
5118
  if (this.requireSignatures && !signatureValid) {
5046
5119
  this._decryptFailCount++;
5120
+ failedIds.push(msg.id);
5047
5121
  const peerForSig = msg.from || "unknown";
5048
5122
  const prevSigFails = this._peerDecryptFails.get(peerForSig) || 0;
5049
5123
  this._peerDecryptFails.set(peerForSig, prevSigFails + 1);
@@ -5073,6 +5147,7 @@ var VoidlyAgent = class _VoidlyAgent {
5073
5147
  });
5074
5148
  } catch {
5075
5149
  this._decryptFailCount++;
5150
+ failedIds.push(msg.id);
5076
5151
  const peerForCatch = msg.from || "unknown";
5077
5152
  const prevCatchFails = this._peerDecryptFails.get(peerForCatch) || 0;
5078
5153
  this._peerDecryptFails.set(peerForCatch, prevCatchFails + 1);
@@ -5087,7 +5162,7 @@ var VoidlyAgent = class _VoidlyAgent {
5087
5162
  this._persistRatchetState().catch(() => {
5088
5163
  });
5089
5164
  }
5090
- return decrypted;
5165
+ return { decrypted, failedIds };
5091
5166
  }
5092
5167
  // ─── Message Management ─────────────────────────────────────────────────────
5093
5168
  /**
@@ -6443,7 +6518,13 @@ var VoidlyAgent = class _VoidlyAgent {
6443
6518
  if (eventType === "message" && dataStr) {
6444
6519
  try {
6445
6520
  const rawMsg = JSON.parse(dataStr);
6446
- const decrypted = await this._decryptMessages([rawMsg]);
6521
+ const { decrypted, failedIds } = await this._decryptMessages([rawMsg]);
6522
+ for (const id of failedIds) {
6523
+ try {
6524
+ await this.markRead(id);
6525
+ } catch {
6526
+ }
6527
+ }
6447
6528
  if (decrypted.length > 0) {
6448
6529
  consecutiveEmpty = 0;
6449
6530
  sseFailures = 0;
package/dist/index.mjs CHANGED
@@ -4210,6 +4210,7 @@ var VoidlyAgent = class _VoidlyAgent {
4210
4210
  } catch {
4211
4211
  }
4212
4212
  }
4213
+ const effectiveConfig = !mlkemSk && config?.postQuantum !== false ? { ...config, postQuantum: false } : config;
4213
4214
  const agent = new _VoidlyAgent({
4214
4215
  did: creds.did,
4215
4216
  apiKey: creds.apiKey,
@@ -4220,7 +4221,7 @@ var VoidlyAgent = class _VoidlyAgent {
4220
4221
  },
4221
4222
  mlkemPublicKey: mlkemPk,
4222
4223
  mlkemSecretKey: mlkemSk
4223
- }, config);
4224
+ }, effectiveConfig);
4224
4225
  if (creds.ratchetStates) {
4225
4226
  for (const [pairId, rs] of Object.entries(creds.ratchetStates)) {
4226
4227
  try {
@@ -4774,16 +4775,37 @@ var VoidlyAgent = class _VoidlyAgent {
4774
4775
  throw new Error(`Receive failed: ${err.error?.message || err.error || res.statusText}`);
4775
4776
  }
4776
4777
  const data = await res.json();
4777
- return this._decryptMessages(data.messages);
4778
+ const { decrypted, failedIds } = await this._decryptMessages(data.messages);
4779
+ if (failedIds.length > 0) {
4780
+ try {
4781
+ await this.markReadBatch(failedIds);
4782
+ } catch {
4783
+ for (const id of failedIds) {
4784
+ try {
4785
+ await this.markRead(id);
4786
+ } catch {
4787
+ }
4788
+ }
4789
+ }
4790
+ }
4791
+ return decrypted;
4778
4792
  }
4779
- /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4793
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports)
4794
+ * Returns decrypted messages AND IDs of messages that failed to decrypt.
4795
+ * Failed messages should be marked as read on the relay — they are permanently
4796
+ * undecryptable and will poison the queue if left unread (Signal-style handling).
4797
+ */
4780
4798
  async _decryptMessages(rawMessages) {
4781
4799
  const decrypted = [];
4800
+ const failedIds = [];
4782
4801
  const resetPeers = /* @__PURE__ */ new Set();
4783
4802
  for (const msg of rawMessages) {
4784
4803
  try {
4785
4804
  if (this._seenMessageIds.has(msg.id)) continue;
4786
- if (resetPeers.has(msg.from)) continue;
4805
+ if (resetPeers.has(msg.from)) {
4806
+ failedIds.push(msg.id);
4807
+ continue;
4808
+ }
4787
4809
  let senderEncPub;
4788
4810
  let senderSignPubBytes = null;
4789
4811
  if (msg.sender_encryption_key) {
@@ -4919,6 +4941,7 @@ var VoidlyAgent = class _VoidlyAgent {
4919
4941
  const skip = targetStep - state.recvStep;
4920
4942
  if (skip > MAX_SKIP) {
4921
4943
  this._decryptFailCount++;
4944
+ failedIds.push(msg.id);
4922
4945
  const peerForSkip = msg.from || "unknown";
4923
4946
  const prevSkipFails = this._peerDecryptFails.get(peerForSkip) || 0;
4924
4947
  this._peerDecryptFails.set(peerForSkip, prevSkipFails + 1);
@@ -4946,6 +4969,55 @@ var VoidlyAgent = class _VoidlyAgent {
4946
4969
  rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, messageKey);
4947
4970
  }
4948
4971
  }
4972
+ if (!rawPlaintext && envelopeDhRatchetKey && this.doubleRatchet && state) {
4973
+ try {
4974
+ const x25519Shared2 = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4975
+ let initialKey2;
4976
+ if (envelopePqCiphertext && this.mlkemSecretKey) {
4977
+ try {
4978
+ const pqCt2 = (0, import_tweetnacl_util.decodeBase64)(envelopePqCiphertext);
4979
+ const kem2 = new MlKem768();
4980
+ const pqShared2 = await kem2.decap(pqCt2, this.mlkemSecretKey);
4981
+ const combined2 = new Uint8Array(x25519Shared2.length + pqShared2.length);
4982
+ combined2.set(x25519Shared2, 0);
4983
+ combined2.set(pqShared2, x25519Shared2.length);
4984
+ initialKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined2));
4985
+ } catch {
4986
+ initialKey2 = x25519Shared2;
4987
+ }
4988
+ } else {
4989
+ initialKey2 = x25519Shared2;
4990
+ }
4991
+ const senderDhPub2 = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
4992
+ const dhOutput2 = import_tweetnacl.default.box.before(senderDhPub2, this.encryptionKeyPair.secretKey);
4993
+ const { newRootKey: rk2, newChainKey: ck2 } = await kdfRK(initialKey2, dhOutput2);
4994
+ let freshCk = ck2;
4995
+ for (let fi = 1; fi < envelopeRatchetStep; fi++) {
4996
+ const { nextChainKey: nck } = await ratchetStep(freshCk);
4997
+ freshCk = nck;
4998
+ }
4999
+ const { nextChainKey: finalCk, messageKey: freshMk } = await ratchetStep(freshCk);
5000
+ const freshPlain = import_tweetnacl.default.secretbox.open(ciphertext, nonce, freshMk);
5001
+ if (freshPlain) {
5002
+ rawPlaintext = freshPlain;
5003
+ state.recvChainKey = finalCk;
5004
+ state.recvStep = envelopeRatchetStep;
5005
+ state.rootKey = rk2;
5006
+ state.dhRecvPubKey = senderDhPub2;
5007
+ state.prevSendStep = state.sendStep;
5008
+ state.dhSendKeyPair = import_tweetnacl.default.box.keyPair();
5009
+ state.sendStep = 0;
5010
+ state.sendChainKey = initialKey2;
5011
+ const dhOut3 = import_tweetnacl.default.box.before(senderDhPub2, state.dhSendKeyPair.secretKey);
5012
+ const kdf3 = await kdfRK(state.rootKey, dhOut3);
5013
+ state.rootKey = kdf3.newRootKey;
5014
+ state.sendChainKey = kdf3.newChainKey;
5015
+ if (state.dhSkippedKeys) state.dhSkippedKeys.clear();
5016
+ if (state.skippedKeys) state.skippedKeys.clear();
5017
+ }
5018
+ } catch {
5019
+ }
5020
+ }
4949
5021
  if (!rawPlaintext) {
4950
5022
  rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
4951
5023
  }
@@ -4954,6 +5026,7 @@ var VoidlyAgent = class _VoidlyAgent {
4954
5026
  }
4955
5027
  if (!rawPlaintext) {
4956
5028
  this._decryptFailCount++;
5029
+ failedIds.push(msg.id);
4957
5030
  const senderForFail = msg.from || "unknown";
4958
5031
  const prevFails = this._peerDecryptFails.get(senderForFail) || 0;
4959
5032
  this._peerDecryptFails.set(senderForFail, prevFails + 1);
@@ -5034,6 +5107,7 @@ var VoidlyAgent = class _VoidlyAgent {
5034
5107
  }
5035
5108
  if (this.requireSignatures && !signatureValid) {
5036
5109
  this._decryptFailCount++;
5110
+ failedIds.push(msg.id);
5037
5111
  const peerForSig = msg.from || "unknown";
5038
5112
  const prevSigFails = this._peerDecryptFails.get(peerForSig) || 0;
5039
5113
  this._peerDecryptFails.set(peerForSig, prevSigFails + 1);
@@ -5063,6 +5137,7 @@ var VoidlyAgent = class _VoidlyAgent {
5063
5137
  });
5064
5138
  } catch {
5065
5139
  this._decryptFailCount++;
5140
+ failedIds.push(msg.id);
5066
5141
  const peerForCatch = msg.from || "unknown";
5067
5142
  const prevCatchFails = this._peerDecryptFails.get(peerForCatch) || 0;
5068
5143
  this._peerDecryptFails.set(peerForCatch, prevCatchFails + 1);
@@ -5077,7 +5152,7 @@ var VoidlyAgent = class _VoidlyAgent {
5077
5152
  this._persistRatchetState().catch(() => {
5078
5153
  });
5079
5154
  }
5080
- return decrypted;
5155
+ return { decrypted, failedIds };
5081
5156
  }
5082
5157
  // ─── Message Management ─────────────────────────────────────────────────────
5083
5158
  /**
@@ -6433,7 +6508,13 @@ var VoidlyAgent = class _VoidlyAgent {
6433
6508
  if (eventType === "message" && dataStr) {
6434
6509
  try {
6435
6510
  const rawMsg = JSON.parse(dataStr);
6436
- const decrypted = await this._decryptMessages([rawMsg]);
6511
+ const { decrypted, failedIds } = await this._decryptMessages([rawMsg]);
6512
+ for (const id of failedIds) {
6513
+ try {
6514
+ await this.markRead(id);
6515
+ } catch {
6516
+ }
6517
+ }
6437
6518
  if (decrypted.length > 0) {
6438
6519
  consecutiveEmpty = 0;
6439
6520
  sseFailures = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidly/agent-sdk",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "E2E encrypted agent-to-agent communication SDK — Double Ratchet, X3DH, deniable auth, ML-KEM-768 post-quantum, SSE streaming, ratchet persistence, multi-relay federation",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",