@voidly/agent-sdk 3.2.7 → 3.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/index.d.ts CHANGED
@@ -96,6 +96,10 @@ interface VoidlyAgentConfig {
96
96
  /** Transport preference for listen() — tries in order, falls back automatically
97
97
  * Default: ['sse', 'long-poll']. Options: 'websocket' | 'sse' | 'long-poll' */
98
98
  transport?: ('websocket' | 'sse' | 'long-poll')[];
99
+ /** Auto-reset ratchet after N consecutive decrypt failures from same peer (default: 10, 0 = disabled) */
100
+ autoResetThreshold?: number;
101
+ /** Callback when a ratchet is auto-reset due to consecutive failures */
102
+ onRatchetReset?: (peerDid: string, failCount: number) => void;
99
103
  }
100
104
  interface ListenOptions {
101
105
  /** Milliseconds between polls (default: 2000, min: 500) */
@@ -192,6 +196,10 @@ declare class VoidlyAgent {
192
196
  private _identityCache;
193
197
  private _seenMessageIds;
194
198
  private _decryptFailCount;
199
+ /** Per-peer consecutive decrypt failure tracking for auto-recovery */
200
+ private _peerDecryptFails;
201
+ /** Max consecutive per-peer decrypt failures before auto-resetting ratchet (0 = disabled) */
202
+ private _autoResetThreshold;
195
203
  private _rpcHandlers;
196
204
  private _rpcPending;
197
205
  private _coverTrafficTimer;
@@ -202,6 +210,7 @@ declare class VoidlyAgent {
202
210
  private _persistPath?;
203
211
  private _persistKey;
204
212
  private _transportPrefs;
213
+ private _onRatchetReset?;
205
214
  private constructor();
206
215
  /**
207
216
  * Register a new agent on the Voidly relay.
@@ -290,6 +299,24 @@ declare class VoidlyAgent {
290
299
  * Useful for detecting key mismatches, attacks, or corruption.
291
300
  */
292
301
  get decryptFailCount(): number;
302
+ /**
303
+ * Get per-peer consecutive decrypt failure counts.
304
+ * Useful for diagnosing which peer conversations have desynchronized ratchets.
305
+ */
306
+ get peerDecryptFails(): Record<string, number>;
307
+ /**
308
+ * Reset ratchet state for a specific peer.
309
+ * This clears all ratchet keys for the conversation, causing the next send
310
+ * to re-initialize a fresh DH exchange. The peer's ratchet will also reset
311
+ * automatically when they receive the new-format message.
312
+ *
313
+ * Use this when ratchet desync is detected (e.g., consecutive decrypt failures).
314
+ * After calling this, both sides need to send a message to re-establish the ratchet.
315
+ *
316
+ * @param peerDid - The DID of the peer whose ratchet should be reset
317
+ * @returns true if a ratchet was found and reset, false if no ratchet existed
318
+ */
319
+ resetRatchet(peerDid: string): boolean;
293
320
  /**
294
321
  * Generate a did:key identifier from this agent's Ed25519 signing key.
295
322
  * did:key is a W3C standard — interoperable across systems.
package/dist/index.js CHANGED
@@ -4082,6 +4082,10 @@ var VoidlyAgent = class _VoidlyAgent {
4082
4082
  this._identityCache = /* @__PURE__ */ new Map();
4083
4083
  this._seenMessageIds = /* @__PURE__ */ new Set();
4084
4084
  this._decryptFailCount = 0;
4085
+ /** Per-peer consecutive decrypt failure tracking for auto-recovery */
4086
+ this._peerDecryptFails = /* @__PURE__ */ new Map();
4087
+ /** Max consecutive per-peer decrypt failures before auto-resetting ratchet (0 = disabled) */
4088
+ this._autoResetThreshold = 10;
4085
4089
  // RPC handlers: method → handler function
4086
4090
  this._rpcHandlers = /* @__PURE__ */ new Map();
4087
4091
  // RPC pending responses: rpc_id → { resolve, reject, timer }
@@ -4117,6 +4121,8 @@ var VoidlyAgent = class _VoidlyAgent {
4117
4121
  this._onLoad = config?.onLoad;
4118
4122
  this._persistPath = config?.persistPath;
4119
4123
  this._transportPrefs = config?.transport || ["sse", "long-poll"];
4124
+ this._autoResetThreshold = config?.autoResetThreshold ?? 10;
4125
+ this._onRatchetReset = config?.onRatchetReset;
4120
4126
  }
4121
4127
  // ─── Factory Methods ────────────────────────────────────────────────────────
4122
4128
  /**
@@ -4519,6 +4525,37 @@ var VoidlyAgent = class _VoidlyAgent {
4519
4525
  get decryptFailCount() {
4520
4526
  return this._decryptFailCount;
4521
4527
  }
4528
+ /**
4529
+ * Get per-peer consecutive decrypt failure counts.
4530
+ * Useful for diagnosing which peer conversations have desynchronized ratchets.
4531
+ */
4532
+ get peerDecryptFails() {
4533
+ return Object.fromEntries(this._peerDecryptFails);
4534
+ }
4535
+ /**
4536
+ * Reset ratchet state for a specific peer.
4537
+ * This clears all ratchet keys for the conversation, causing the next send
4538
+ * to re-initialize a fresh DH exchange. The peer's ratchet will also reset
4539
+ * automatically when they receive the new-format message.
4540
+ *
4541
+ * Use this when ratchet desync is detected (e.g., consecutive decrypt failures).
4542
+ * After calling this, both sides need to send a message to re-establish the ratchet.
4543
+ *
4544
+ * @param peerDid - The DID of the peer whose ratchet should be reset
4545
+ * @returns true if a ratchet was found and reset, false if no ratchet existed
4546
+ */
4547
+ resetRatchet(peerDid) {
4548
+ const sendPairId = `${this.did}:${peerDid}`;
4549
+ const recvPairId = `${peerDid}:${this.did}`;
4550
+ const hadSend = this._ratchetStates.delete(sendPairId);
4551
+ const hadRecv = this._ratchetStates.delete(recvPairId);
4552
+ this._peerDecryptFails.delete(peerDid);
4553
+ if (hadSend || hadRecv) {
4554
+ this._persistRatchetState().catch(() => {
4555
+ });
4556
+ }
4557
+ return hadSend || hadRecv;
4558
+ }
4522
4559
  /**
4523
4560
  * Generate a did:key identifier from this agent's Ed25519 signing key.
4524
4561
  * did:key is a W3C standard — interoperable across systems.
@@ -4752,9 +4789,11 @@ var VoidlyAgent = class _VoidlyAgent {
4752
4789
  /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4753
4790
  async _decryptMessages(rawMessages) {
4754
4791
  const decrypted = [];
4792
+ const resetPeers = /* @__PURE__ */ new Set();
4755
4793
  for (const msg of rawMessages) {
4756
4794
  try {
4757
4795
  if (this._seenMessageIds.has(msg.id)) continue;
4796
+ if (resetPeers.has(msg.from)) continue;
4758
4797
  let senderEncPub;
4759
4798
  let senderSignPubBytes = null;
4760
4799
  if (msg.sender_encryption_key) {
@@ -4890,6 +4929,14 @@ var VoidlyAgent = class _VoidlyAgent {
4890
4929
  const skip = targetStep - state.recvStep;
4891
4930
  if (skip > MAX_SKIP) {
4892
4931
  this._decryptFailCount++;
4932
+ const peerForSkip = msg.from || "unknown";
4933
+ const prevSkipFails = this._peerDecryptFails.get(peerForSkip) || 0;
4934
+ this._peerDecryptFails.set(peerForSkip, prevSkipFails + 1);
4935
+ if (this._autoResetThreshold > 0 && prevSkipFails + 1 >= this._autoResetThreshold) {
4936
+ this.resetRatchet(peerForSkip);
4937
+ resetPeers.add(peerForSkip);
4938
+ this._onRatchetReset?.(peerForSkip, prevSkipFails + 1);
4939
+ }
4893
4940
  continue;
4894
4941
  } else {
4895
4942
  let ck = state.recvChainKey;
@@ -4917,8 +4964,22 @@ var VoidlyAgent = class _VoidlyAgent {
4917
4964
  }
4918
4965
  if (!rawPlaintext) {
4919
4966
  this._decryptFailCount++;
4967
+ const senderForFail = msg.from || "unknown";
4968
+ const prevFails = this._peerDecryptFails.get(senderForFail) || 0;
4969
+ this._peerDecryptFails.set(senderForFail, prevFails + 1);
4970
+ if (this._autoResetThreshold > 0 && prevFails + 1 >= this._autoResetThreshold) {
4971
+ this.resetRatchet(senderForFail);
4972
+ resetPeers.add(senderForFail);
4973
+ this._onRatchetReset?.(senderForFail, prevFails + 1);
4974
+ }
4920
4975
  continue;
4921
4976
  }
4977
+ {
4978
+ const senderForSuccess = msg.from || "unknown";
4979
+ if (this._peerDecryptFails.has(senderForSuccess)) {
4980
+ this._peerDecryptFails.delete(senderForSuccess);
4981
+ }
4982
+ }
4922
4983
  let plaintextBytes = rawPlaintext;
4923
4984
  let wasPadded = false;
4924
4985
  let wasSealed = false;
@@ -4983,6 +5044,9 @@ var VoidlyAgent = class _VoidlyAgent {
4983
5044
  }
4984
5045
  if (this.requireSignatures && !signatureValid) {
4985
5046
  this._decryptFailCount++;
5047
+ const peerForSig = msg.from || "unknown";
5048
+ const prevSigFails = this._peerDecryptFails.get(peerForSig) || 0;
5049
+ this._peerDecryptFails.set(peerForSig, prevSigFails + 1);
4986
5050
  continue;
4987
5051
  }
4988
5052
  this._seenMessageIds.add(msg.id);
@@ -5009,9 +5073,17 @@ var VoidlyAgent = class _VoidlyAgent {
5009
5073
  });
5010
5074
  } catch {
5011
5075
  this._decryptFailCount++;
5076
+ const peerForCatch = msg.from || "unknown";
5077
+ const prevCatchFails = this._peerDecryptFails.get(peerForCatch) || 0;
5078
+ this._peerDecryptFails.set(peerForCatch, prevCatchFails + 1);
5079
+ if (this._autoResetThreshold > 0 && prevCatchFails + 1 >= this._autoResetThreshold) {
5080
+ this.resetRatchet(peerForCatch);
5081
+ resetPeers.add(peerForCatch);
5082
+ this._onRatchetReset?.(peerForCatch, prevCatchFails + 1);
5083
+ }
5012
5084
  }
5013
5085
  }
5014
- if (decrypted.length > 0) {
5086
+ if (decrypted.length > 0 || this._peerDecryptFails.size > 0) {
5015
5087
  this._persistRatchetState().catch(() => {
5016
5088
  });
5017
5089
  }
package/dist/index.mjs CHANGED
@@ -4072,6 +4072,10 @@ var VoidlyAgent = class _VoidlyAgent {
4072
4072
  this._identityCache = /* @__PURE__ */ new Map();
4073
4073
  this._seenMessageIds = /* @__PURE__ */ new Set();
4074
4074
  this._decryptFailCount = 0;
4075
+ /** Per-peer consecutive decrypt failure tracking for auto-recovery */
4076
+ this._peerDecryptFails = /* @__PURE__ */ new Map();
4077
+ /** Max consecutive per-peer decrypt failures before auto-resetting ratchet (0 = disabled) */
4078
+ this._autoResetThreshold = 10;
4075
4079
  // RPC handlers: method → handler function
4076
4080
  this._rpcHandlers = /* @__PURE__ */ new Map();
4077
4081
  // RPC pending responses: rpc_id → { resolve, reject, timer }
@@ -4107,6 +4111,8 @@ var VoidlyAgent = class _VoidlyAgent {
4107
4111
  this._onLoad = config?.onLoad;
4108
4112
  this._persistPath = config?.persistPath;
4109
4113
  this._transportPrefs = config?.transport || ["sse", "long-poll"];
4114
+ this._autoResetThreshold = config?.autoResetThreshold ?? 10;
4115
+ this._onRatchetReset = config?.onRatchetReset;
4110
4116
  }
4111
4117
  // ─── Factory Methods ────────────────────────────────────────────────────────
4112
4118
  /**
@@ -4509,6 +4515,37 @@ var VoidlyAgent = class _VoidlyAgent {
4509
4515
  get decryptFailCount() {
4510
4516
  return this._decryptFailCount;
4511
4517
  }
4518
+ /**
4519
+ * Get per-peer consecutive decrypt failure counts.
4520
+ * Useful for diagnosing which peer conversations have desynchronized ratchets.
4521
+ */
4522
+ get peerDecryptFails() {
4523
+ return Object.fromEntries(this._peerDecryptFails);
4524
+ }
4525
+ /**
4526
+ * Reset ratchet state for a specific peer.
4527
+ * This clears all ratchet keys for the conversation, causing the next send
4528
+ * to re-initialize a fresh DH exchange. The peer's ratchet will also reset
4529
+ * automatically when they receive the new-format message.
4530
+ *
4531
+ * Use this when ratchet desync is detected (e.g., consecutive decrypt failures).
4532
+ * After calling this, both sides need to send a message to re-establish the ratchet.
4533
+ *
4534
+ * @param peerDid - The DID of the peer whose ratchet should be reset
4535
+ * @returns true if a ratchet was found and reset, false if no ratchet existed
4536
+ */
4537
+ resetRatchet(peerDid) {
4538
+ const sendPairId = `${this.did}:${peerDid}`;
4539
+ const recvPairId = `${peerDid}:${this.did}`;
4540
+ const hadSend = this._ratchetStates.delete(sendPairId);
4541
+ const hadRecv = this._ratchetStates.delete(recvPairId);
4542
+ this._peerDecryptFails.delete(peerDid);
4543
+ if (hadSend || hadRecv) {
4544
+ this._persistRatchetState().catch(() => {
4545
+ });
4546
+ }
4547
+ return hadSend || hadRecv;
4548
+ }
4512
4549
  /**
4513
4550
  * Generate a did:key identifier from this agent's Ed25519 signing key.
4514
4551
  * did:key is a W3C standard — interoperable across systems.
@@ -4742,9 +4779,11 @@ var VoidlyAgent = class _VoidlyAgent {
4742
4779
  /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4743
4780
  async _decryptMessages(rawMessages) {
4744
4781
  const decrypted = [];
4782
+ const resetPeers = /* @__PURE__ */ new Set();
4745
4783
  for (const msg of rawMessages) {
4746
4784
  try {
4747
4785
  if (this._seenMessageIds.has(msg.id)) continue;
4786
+ if (resetPeers.has(msg.from)) continue;
4748
4787
  let senderEncPub;
4749
4788
  let senderSignPubBytes = null;
4750
4789
  if (msg.sender_encryption_key) {
@@ -4880,6 +4919,14 @@ var VoidlyAgent = class _VoidlyAgent {
4880
4919
  const skip = targetStep - state.recvStep;
4881
4920
  if (skip > MAX_SKIP) {
4882
4921
  this._decryptFailCount++;
4922
+ const peerForSkip = msg.from || "unknown";
4923
+ const prevSkipFails = this._peerDecryptFails.get(peerForSkip) || 0;
4924
+ this._peerDecryptFails.set(peerForSkip, prevSkipFails + 1);
4925
+ if (this._autoResetThreshold > 0 && prevSkipFails + 1 >= this._autoResetThreshold) {
4926
+ this.resetRatchet(peerForSkip);
4927
+ resetPeers.add(peerForSkip);
4928
+ this._onRatchetReset?.(peerForSkip, prevSkipFails + 1);
4929
+ }
4883
4930
  continue;
4884
4931
  } else {
4885
4932
  let ck = state.recvChainKey;
@@ -4907,8 +4954,22 @@ var VoidlyAgent = class _VoidlyAgent {
4907
4954
  }
4908
4955
  if (!rawPlaintext) {
4909
4956
  this._decryptFailCount++;
4957
+ const senderForFail = msg.from || "unknown";
4958
+ const prevFails = this._peerDecryptFails.get(senderForFail) || 0;
4959
+ this._peerDecryptFails.set(senderForFail, prevFails + 1);
4960
+ if (this._autoResetThreshold > 0 && prevFails + 1 >= this._autoResetThreshold) {
4961
+ this.resetRatchet(senderForFail);
4962
+ resetPeers.add(senderForFail);
4963
+ this._onRatchetReset?.(senderForFail, prevFails + 1);
4964
+ }
4910
4965
  continue;
4911
4966
  }
4967
+ {
4968
+ const senderForSuccess = msg.from || "unknown";
4969
+ if (this._peerDecryptFails.has(senderForSuccess)) {
4970
+ this._peerDecryptFails.delete(senderForSuccess);
4971
+ }
4972
+ }
4912
4973
  let plaintextBytes = rawPlaintext;
4913
4974
  let wasPadded = false;
4914
4975
  let wasSealed = false;
@@ -4973,6 +5034,9 @@ var VoidlyAgent = class _VoidlyAgent {
4973
5034
  }
4974
5035
  if (this.requireSignatures && !signatureValid) {
4975
5036
  this._decryptFailCount++;
5037
+ const peerForSig = msg.from || "unknown";
5038
+ const prevSigFails = this._peerDecryptFails.get(peerForSig) || 0;
5039
+ this._peerDecryptFails.set(peerForSig, prevSigFails + 1);
4976
5040
  continue;
4977
5041
  }
4978
5042
  this._seenMessageIds.add(msg.id);
@@ -4999,9 +5063,17 @@ var VoidlyAgent = class _VoidlyAgent {
4999
5063
  });
5000
5064
  } catch {
5001
5065
  this._decryptFailCount++;
5066
+ const peerForCatch = msg.from || "unknown";
5067
+ const prevCatchFails = this._peerDecryptFails.get(peerForCatch) || 0;
5068
+ this._peerDecryptFails.set(peerForCatch, prevCatchFails + 1);
5069
+ if (this._autoResetThreshold > 0 && prevCatchFails + 1 >= this._autoResetThreshold) {
5070
+ this.resetRatchet(peerForCatch);
5071
+ resetPeers.add(peerForCatch);
5072
+ this._onRatchetReset?.(peerForCatch, prevCatchFails + 1);
5073
+ }
5002
5074
  }
5003
5075
  }
5004
- if (decrypted.length > 0) {
5076
+ if (decrypted.length > 0 || this._peerDecryptFails.size > 0) {
5005
5077
  this._persistRatchetState().catch(() => {
5006
5078
  });
5007
5079
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidly/agent-sdk",
3
- "version": "3.2.7",
3
+ "version": "3.3.0",
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",