@voidly/agent-sdk 2.0.0 → 2.1.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.mts CHANGED
@@ -60,6 +60,10 @@ interface VoidlyAgentConfig {
60
60
  padding?: boolean;
61
61
  /** Enable sealed sender — hide sender DID from relay metadata (default: false) */
62
62
  sealedSender?: boolean;
63
+ /** Reject messages with invalid signatures (default: false — returns signatureValid: false) */
64
+ requireSignatures?: boolean;
65
+ /** Request timeout in milliseconds (default: 30000) */
66
+ timeout?: number;
63
67
  }
64
68
  interface ListenOptions {
65
69
  /** Milliseconds between polls (default: 2000, min: 500) */
@@ -137,11 +141,16 @@ declare class VoidlyAgent {
137
141
  private fallbackRelays;
138
142
  private paddingEnabled;
139
143
  private sealedSender;
144
+ private requireSignatures;
145
+ private timeout;
140
146
  private _pinnedDids;
141
147
  private _listeners;
142
148
  private _conversations;
143
149
  private _offlineQueue;
144
150
  private _ratchetStates;
151
+ private _identityCache;
152
+ private _seenMessageIds;
153
+ private _decryptFailCount;
145
154
  private constructor();
146
155
  /**
147
156
  * Register a new agent on the Voidly relay.
@@ -173,6 +182,17 @@ declare class VoidlyAgent {
173
182
  signingPublicKey: string;
174
183
  encryptionPublicKey: string;
175
184
  };
185
+ /**
186
+ * Get the number of messages that failed to decrypt.
187
+ * Useful for detecting key mismatches, attacks, or corruption.
188
+ */
189
+ get decryptFailCount(): number;
190
+ /**
191
+ * Generate a did:key identifier from this agent's Ed25519 signing key.
192
+ * did:key is a W3C standard — interoperable across systems.
193
+ * Format: did:key:z6Mk{base58-multicodec-ed25519-pubkey}
194
+ */
195
+ get didKey(): string;
176
196
  /**
177
197
  * Send an E2E encrypted message with hardened security.
178
198
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
@@ -644,7 +664,8 @@ declare class VoidlyAgent {
644
664
  }, signingPublicKey: string): boolean;
645
665
  /**
646
666
  * Store an encrypted key-value pair in persistent memory.
647
- * Values are encrypted with a key derived from your API key — only you can read them.
667
+ * Values are encrypted CLIENT-SIDE with nacl.secretbox before sending to relay.
668
+ * The relay never sees plaintext values — true E2E encrypted storage.
648
669
  */
649
670
  memorySet(namespace: string, key: string, value: unknown, options?: {
650
671
  valueType?: string;
@@ -657,7 +678,7 @@ declare class VoidlyAgent {
657
678
  }>;
658
679
  /**
659
680
  * Retrieve a value from persistent memory.
660
- * Decrypted server-side using your API key derivation.
681
+ * Decrypted CLIENT-SIDE relay never sees plaintext.
661
682
  */
662
683
  memoryGet(namespace: string, key: string): Promise<{
663
684
  namespace: string;
package/dist/index.d.ts CHANGED
@@ -60,6 +60,10 @@ interface VoidlyAgentConfig {
60
60
  padding?: boolean;
61
61
  /** Enable sealed sender — hide sender DID from relay metadata (default: false) */
62
62
  sealedSender?: boolean;
63
+ /** Reject messages with invalid signatures (default: false — returns signatureValid: false) */
64
+ requireSignatures?: boolean;
65
+ /** Request timeout in milliseconds (default: 30000) */
66
+ timeout?: number;
63
67
  }
64
68
  interface ListenOptions {
65
69
  /** Milliseconds between polls (default: 2000, min: 500) */
@@ -137,11 +141,16 @@ declare class VoidlyAgent {
137
141
  private fallbackRelays;
138
142
  private paddingEnabled;
139
143
  private sealedSender;
144
+ private requireSignatures;
145
+ private timeout;
140
146
  private _pinnedDids;
141
147
  private _listeners;
142
148
  private _conversations;
143
149
  private _offlineQueue;
144
150
  private _ratchetStates;
151
+ private _identityCache;
152
+ private _seenMessageIds;
153
+ private _decryptFailCount;
145
154
  private constructor();
146
155
  /**
147
156
  * Register a new agent on the Voidly relay.
@@ -173,6 +182,17 @@ declare class VoidlyAgent {
173
182
  signingPublicKey: string;
174
183
  encryptionPublicKey: string;
175
184
  };
185
+ /**
186
+ * Get the number of messages that failed to decrypt.
187
+ * Useful for detecting key mismatches, attacks, or corruption.
188
+ */
189
+ get decryptFailCount(): number;
190
+ /**
191
+ * Generate a did:key identifier from this agent's Ed25519 signing key.
192
+ * did:key is a W3C standard — interoperable across systems.
193
+ * Format: did:key:z6Mk{base58-multicodec-ed25519-pubkey}
194
+ */
195
+ get didKey(): string;
176
196
  /**
177
197
  * Send an E2E encrypted message with hardened security.
178
198
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
@@ -644,7 +664,8 @@ declare class VoidlyAgent {
644
664
  }, signingPublicKey: string): boolean;
645
665
  /**
646
666
  * Store an encrypted key-value pair in persistent memory.
647
- * Values are encrypted with a key derived from your API key — only you can read them.
667
+ * Values are encrypted CLIENT-SIDE with nacl.secretbox before sending to relay.
668
+ * The relay never sees plaintext values — true E2E encrypted storage.
648
669
  */
649
670
  memorySet(namespace: string, key: string, value: unknown, options?: {
650
671
  valueType?: string;
@@ -657,7 +678,7 @@ declare class VoidlyAgent {
657
678
  }>;
658
679
  /**
659
680
  * Retrieve a value from persistent memory.
660
- * Decrypted server-side using your API key derivation.
681
+ * Decrypted CLIENT-SIDE relay never sees plaintext.
661
682
  */
662
683
  memoryGet(namespace: string, key: string): Promise<{
663
684
  namespace: string;
package/dist/index.js CHANGED
@@ -2405,6 +2405,38 @@ function unsealEnvelope(plaintext) {
2405
2405
  return null;
2406
2406
  }
2407
2407
  }
2408
+ var PROTO_MARKER = 86;
2409
+ var FLAG_PADDED = 1;
2410
+ var FLAG_SEALED = 2;
2411
+ var FLAG_RATCHET = 4;
2412
+ function makeProtoHeader(flags, ratchetStep2) {
2413
+ return new Uint8Array([PROTO_MARKER, flags, ratchetStep2 >> 8 & 255, ratchetStep2 & 255]);
2414
+ }
2415
+ function parseProtoHeader(data) {
2416
+ if (data.length < 4 || data[0] !== PROTO_MARKER) return null;
2417
+ return {
2418
+ flags: data[1],
2419
+ ratchetStep: data[2] << 8 | data[3],
2420
+ content: data.slice(4)
2421
+ };
2422
+ }
2423
+ var MAX_SKIP = 200;
2424
+ var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
2425
+ function toBase58(bytes) {
2426
+ if (bytes.length === 0) return "1";
2427
+ let result = "";
2428
+ let num = BigInt("0x" + Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""));
2429
+ while (num > 0n) {
2430
+ const remainder = num % 58n;
2431
+ num = num / 58n;
2432
+ result = BASE58_ALPHABET[Number(remainder)] + result;
2433
+ }
2434
+ for (const byte of bytes) {
2435
+ if (byte === 0) result = "1" + result;
2436
+ else break;
2437
+ }
2438
+ return result || "1";
2439
+ }
2408
2440
  var VoidlyAgent = class _VoidlyAgent {
2409
2441
  constructor(identity, config) {
2410
2442
  this._pinnedDids = /* @__PURE__ */ new Set();
@@ -2412,6 +2444,9 @@ var VoidlyAgent = class _VoidlyAgent {
2412
2444
  this._conversations = /* @__PURE__ */ new Map();
2413
2445
  this._offlineQueue = [];
2414
2446
  this._ratchetStates = /* @__PURE__ */ new Map();
2447
+ this._identityCache = /* @__PURE__ */ new Map();
2448
+ this._seenMessageIds = /* @__PURE__ */ new Set();
2449
+ this._decryptFailCount = 0;
2415
2450
  this.did = identity.did;
2416
2451
  this.apiKey = identity.apiKey;
2417
2452
  this.signingKeyPair = identity.signingKeyPair;
@@ -2422,6 +2457,8 @@ var VoidlyAgent = class _VoidlyAgent {
2422
2457
  this.fallbackRelays = config?.fallbackRelays || [];
2423
2458
  this.paddingEnabled = config?.padding !== false;
2424
2459
  this.sealedSender = config?.sealedSender || false;
2460
+ this.requireSignatures = config?.requireSignatures || false;
2461
+ this.timeout = config?.timeout ?? 3e4;
2425
2462
  }
2426
2463
  // ─── Factory Methods ────────────────────────────────────────────────────────
2427
2464
  /**
@@ -2460,8 +2497,29 @@ var VoidlyAgent = class _VoidlyAgent {
2460
2497
  * Use this to resume an agent across sessions.
2461
2498
  */
2462
2499
  static fromCredentials(creds, config) {
2463
- const signingSecret = (0, import_tweetnacl_util.decodeBase64)(creds.signingSecretKey);
2464
- const encryptionSecret = (0, import_tweetnacl_util.decodeBase64)(creds.encryptionSecretKey);
2500
+ if (!creds.did || !creds.did.startsWith("did:")) {
2501
+ throw new Error('Invalid credentials: did must start with "did:"');
2502
+ }
2503
+ if (!creds.apiKey || creds.apiKey.length < 8) {
2504
+ throw new Error("Invalid credentials: apiKey is missing or too short");
2505
+ }
2506
+ if (!creds.signingSecretKey || !creds.encryptionSecretKey) {
2507
+ throw new Error("Invalid credentials: secret keys are required");
2508
+ }
2509
+ let signingSecret;
2510
+ let encryptionSecret;
2511
+ try {
2512
+ signingSecret = (0, import_tweetnacl_util.decodeBase64)(creds.signingSecretKey);
2513
+ encryptionSecret = (0, import_tweetnacl_util.decodeBase64)(creds.encryptionSecretKey);
2514
+ } catch {
2515
+ throw new Error("Invalid credentials: secret keys must be valid base64");
2516
+ }
2517
+ if (signingSecret.length !== 64) {
2518
+ throw new Error(`Invalid credentials: signing key must be 64 bytes, got ${signingSecret.length}`);
2519
+ }
2520
+ if (encryptionSecret.length !== 32) {
2521
+ throw new Error(`Invalid credentials: encryption key must be 32 bytes, got ${encryptionSecret.length}`);
2522
+ }
2465
2523
  return new _VoidlyAgent({
2466
2524
  did: creds.did,
2467
2525
  apiKey: creds.apiKey,
@@ -2486,6 +2544,25 @@ var VoidlyAgent = class _VoidlyAgent {
2486
2544
  encryptionPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.encryptionKeyPair.publicKey)
2487
2545
  };
2488
2546
  }
2547
+ /**
2548
+ * Get the number of messages that failed to decrypt.
2549
+ * Useful for detecting key mismatches, attacks, or corruption.
2550
+ */
2551
+ get decryptFailCount() {
2552
+ return this._decryptFailCount;
2553
+ }
2554
+ /**
2555
+ * Generate a did:key identifier from this agent's Ed25519 signing key.
2556
+ * did:key is a W3C standard — interoperable across systems.
2557
+ * Format: did:key:z6Mk{base58-multicodec-ed25519-pubkey}
2558
+ */
2559
+ get didKey() {
2560
+ const multicodec = new Uint8Array(2 + this.signingKeyPair.publicKey.length);
2561
+ multicodec[0] = 237;
2562
+ multicodec[1] = 1;
2563
+ multicodec.set(this.signingKeyPair.publicKey, 2);
2564
+ return `did:key:z${toBase58(multicodec)}`;
2565
+ }
2489
2566
  // ─── Messaging ──────────────────────────────────────────────────────────────
2490
2567
  /**
2491
2568
  * Send an E2E encrypted message with hardened security.
@@ -2516,29 +2593,39 @@ var VoidlyAgent = class _VoidlyAgent {
2516
2593
  if (useSealed) {
2517
2594
  plaintext = sealEnvelope(this.did, message);
2518
2595
  }
2519
- let messageBytes;
2596
+ let contentBytes;
2520
2597
  if (usePadding) {
2521
- messageBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2598
+ contentBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2522
2599
  } else {
2523
- messageBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2600
+ contentBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2524
2601
  }
2525
2602
  const pairId = `${this.did}:${recipientDid}`;
2526
- const ratchetState = this._ratchetStates.get(pairId);
2527
- let ratchetStep_n = 0;
2528
- if (ratchetState) {
2529
- const { nextChainKey, messageKey } = await ratchetStep(ratchetState.chainKey);
2530
- ratchetState.chainKey = nextChainKey;
2531
- ratchetState.step++;
2532
- ratchetStep_n = ratchetState.step;
2533
- } else {
2603
+ let state = this._ratchetStates.get(pairId);
2604
+ if (!state) {
2534
2605
  const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
2535
- this._ratchetStates.set(pairId, {
2536
- chainKey: sharedSecret,
2537
- step: 0
2538
- });
2606
+ state = {
2607
+ sendChainKey: sharedSecret,
2608
+ sendStep: 0,
2609
+ recvChainKey: sharedSecret,
2610
+ // Will be synced on first receive
2611
+ recvStep: 0,
2612
+ skippedKeys: /* @__PURE__ */ new Map()
2613
+ };
2614
+ this._ratchetStates.set(pairId, state);
2539
2615
  }
2540
- const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
2541
- const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
2616
+ const { nextChainKey, messageKey } = await ratchetStep(state.sendChainKey);
2617
+ state.sendChainKey = nextChainKey;
2618
+ state.sendStep++;
2619
+ const currentStep = state.sendStep;
2620
+ let flags = FLAG_RATCHET;
2621
+ if (usePadding) flags |= FLAG_PADDED;
2622
+ if (useSealed) flags |= FLAG_SEALED;
2623
+ const header = makeProtoHeader(flags, currentStep);
2624
+ const messageBytes = new Uint8Array(header.length + contentBytes.length);
2625
+ messageBytes.set(header, 0);
2626
+ messageBytes.set(contentBytes, header.length);
2627
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
2628
+ const ciphertext = import_tweetnacl.default.secretbox(messageBytes, nonce, messageKey);
2542
2629
  if (!ciphertext) {
2543
2630
  throw new Error("Encryption failed");
2544
2631
  }
@@ -2547,7 +2634,8 @@ var VoidlyAgent = class _VoidlyAgent {
2547
2634
  to: recipientDid,
2548
2635
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2549
2636
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
2550
- ciphertext_hash: await sha256((0, import_tweetnacl_util.encodeBase64)(ciphertext))
2637
+ ciphertext_hash: await sha256((0, import_tweetnacl_util.encodeBase64)(ciphertext)),
2638
+ ratchet_step: currentStep
2551
2639
  });
2552
2640
  const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
2553
2641
  const payload = {
@@ -2623,13 +2711,85 @@ var VoidlyAgent = class _VoidlyAgent {
2623
2711
  const decrypted = [];
2624
2712
  for (const msg of data.messages) {
2625
2713
  try {
2714
+ if (this._seenMessageIds.has(msg.id)) continue;
2626
2715
  const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
2627
2716
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
2628
2717
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
2629
- const rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2630
- if (!rawPlaintext) continue;
2718
+ let rawPlaintext = null;
2719
+ let envelopeRatchetStep = 0;
2720
+ if (msg.envelope) {
2721
+ try {
2722
+ const env = JSON.parse(msg.envelope);
2723
+ if (typeof env.ratchet_step === "number") {
2724
+ envelopeRatchetStep = env.ratchet_step;
2725
+ }
2726
+ } catch {
2727
+ }
2728
+ }
2729
+ if (envelopeRatchetStep > 0) {
2730
+ const pairId = `${msg.from}:${this.did}`;
2731
+ let state = this._ratchetStates.get(pairId);
2732
+ if (!state) {
2733
+ const sharedSecret = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
2734
+ state = {
2735
+ sendChainKey: sharedSecret,
2736
+ // Our sending chain to this peer
2737
+ sendStep: 0,
2738
+ recvChainKey: sharedSecret,
2739
+ // Their sending chain (our receiving)
2740
+ recvStep: 0,
2741
+ skippedKeys: /* @__PURE__ */ new Map()
2742
+ };
2743
+ this._ratchetStates.set(pairId, state);
2744
+ }
2745
+ const targetStep = envelopeRatchetStep;
2746
+ if (state.skippedKeys.has(targetStep)) {
2747
+ const mk = state.skippedKeys.get(targetStep);
2748
+ rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
2749
+ state.skippedKeys.delete(targetStep);
2750
+ } else if (targetStep > state.recvStep) {
2751
+ const skip = targetStep - state.recvStep;
2752
+ if (skip > MAX_SKIP) {
2753
+ rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2754
+ } else {
2755
+ let ck = state.recvChainKey;
2756
+ for (let i = state.recvStep + 1; i < targetStep; i++) {
2757
+ const { nextChainKey: nextChainKey2, messageKey: skippedMk } = await ratchetStep(ck);
2758
+ state.skippedKeys.set(i, skippedMk);
2759
+ ck = nextChainKey2;
2760
+ if (state.skippedKeys.size > MAX_SKIP) {
2761
+ const oldest = state.skippedKeys.keys().next().value;
2762
+ if (oldest !== void 0) state.skippedKeys.delete(oldest);
2763
+ }
2764
+ }
2765
+ const { nextChainKey, messageKey } = await ratchetStep(ck);
2766
+ state.recvChainKey = nextChainKey;
2767
+ state.recvStep = targetStep;
2768
+ rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, messageKey);
2769
+ }
2770
+ }
2771
+ if (!rawPlaintext) {
2772
+ rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2773
+ }
2774
+ } else {
2775
+ rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2776
+ }
2777
+ if (!rawPlaintext) {
2778
+ this._decryptFailCount++;
2779
+ continue;
2780
+ }
2631
2781
  let plaintextBytes = rawPlaintext;
2632
- if (rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
2782
+ let wasPadded = false;
2783
+ let wasSealed = false;
2784
+ const proto = parseProtoHeader(rawPlaintext);
2785
+ if (proto) {
2786
+ wasPadded = !!(proto.flags & FLAG_PADDED);
2787
+ wasSealed = !!(proto.flags & FLAG_SEALED);
2788
+ plaintextBytes = proto.content;
2789
+ }
2790
+ if (wasPadded) {
2791
+ plaintextBytes = unpadMessage(plaintextBytes);
2792
+ } else if (!proto && rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
2633
2793
  const unpadded = unpadMessage(rawPlaintext);
2634
2794
  if (unpadded.length < rawPlaintext.length) {
2635
2795
  plaintextBytes = unpadded;
@@ -2637,10 +2797,12 @@ var VoidlyAgent = class _VoidlyAgent {
2637
2797
  }
2638
2798
  let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
2639
2799
  let senderDid = msg.from;
2640
- const unsealed = unsealEnvelope(content);
2641
- if (unsealed) {
2642
- content = unsealed.msg;
2643
- senderDid = unsealed.from;
2800
+ if (wasSealed || !proto) {
2801
+ const unsealed = unsealEnvelope(content);
2802
+ if (unsealed) {
2803
+ content = unsealed.msg;
2804
+ senderDid = unsealed.from;
2805
+ }
2644
2806
  }
2645
2807
  let signatureValid = false;
2646
2808
  try {
@@ -2661,6 +2823,15 @@ var VoidlyAgent = class _VoidlyAgent {
2661
2823
  } catch {
2662
2824
  signatureValid = false;
2663
2825
  }
2826
+ if (this.requireSignatures && !signatureValid) {
2827
+ this._decryptFailCount++;
2828
+ continue;
2829
+ }
2830
+ this._seenMessageIds.add(msg.id);
2831
+ if (this._seenMessageIds.size > 1e4) {
2832
+ const first = this._seenMessageIds.values().next().value;
2833
+ if (first !== void 0) this._seenMessageIds.delete(first);
2834
+ }
2664
2835
  decrypted.push({
2665
2836
  id: msg.id,
2666
2837
  from: senderDid,
@@ -2675,6 +2846,7 @@ var VoidlyAgent = class _VoidlyAgent {
2675
2846
  expiresAt: msg.expires_at
2676
2847
  });
2677
2848
  } catch {
2849
+ this._decryptFailCount++;
2678
2850
  }
2679
2851
  }
2680
2852
  return decrypted;
@@ -2725,9 +2897,19 @@ var VoidlyAgent = class _VoidlyAgent {
2725
2897
  * Look up an agent's public profile and keys.
2726
2898
  */
2727
2899
  async getIdentity(did) {
2900
+ const cached = this._identityCache.get(did);
2901
+ if (cached && Date.now() - cached.cachedAt < 3e5) {
2902
+ return cached.profile;
2903
+ }
2728
2904
  const res = await fetch(`${this.baseUrl}/v1/agent/identity/${did}`);
2729
2905
  if (!res.ok) return null;
2730
- return await res.json();
2906
+ const profile = await res.json();
2907
+ this._identityCache.set(did, { profile, cachedAt: Date.now() });
2908
+ if (this._identityCache.size > 500) {
2909
+ const oldest = this._identityCache.keys().next().value;
2910
+ if (oldest !== void 0) this._identityCache.delete(oldest);
2911
+ }
2912
+ return profile;
2731
2913
  }
2732
2914
  /**
2733
2915
  * Search for agents by name or capability.
@@ -2787,10 +2969,14 @@ var VoidlyAgent = class _VoidlyAgent {
2787
2969
  * Delete a webhook.
2788
2970
  */
2789
2971
  async deleteWebhook(webhookId) {
2790
- await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2972
+ const res = await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2791
2973
  method: "DELETE",
2792
2974
  headers: { "X-Agent-Key": this.apiKey }
2793
2975
  });
2976
+ if (!res.ok) {
2977
+ const err = await res.json().catch(() => ({}));
2978
+ throw new Error(`Webhook delete failed: ${err.error || res.statusText}`);
2979
+ }
2794
2980
  }
2795
2981
  /**
2796
2982
  * Verify a webhook payload signature (for use in your webhook handler).
@@ -3408,13 +3594,21 @@ var VoidlyAgent = class _VoidlyAgent {
3408
3594
  * The relay finds matching agents and creates individual tasks for each.
3409
3595
  */
3410
3596
  async broadcastTask(options) {
3597
+ const inputBytes = (0, import_tweetnacl_util.decodeUTF8)(options.input);
3598
+ const broadcastNonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
3599
+ const broadcastEncrypted = import_tweetnacl.default.secretbox(inputBytes, broadcastNonce, import_tweetnacl.default.box.before(
3600
+ this.encryptionKeyPair.publicKey,
3601
+ this.encryptionKeyPair.secretKey
3602
+ ));
3603
+ const broadcastSig = import_tweetnacl.default.sign.detached(inputBytes, this.signingKeyPair.secretKey);
3411
3604
  const res = await fetch(`${this.baseUrl}/v1/agent/tasks/broadcast`, {
3412
3605
  method: "POST",
3413
3606
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3414
3607
  body: JSON.stringify({
3415
3608
  capability: options.capability,
3416
- encrypted_input: (0, import_tweetnacl_util.encodeBase64)((0, import_tweetnacl_util.decodeUTF8)(options.input)),
3417
- input_nonce: (0, import_tweetnacl_util.encodeBase64)(import_tweetnacl.default.randomBytes(24)),
3609
+ encrypted_input: (0, import_tweetnacl_util.encodeBase64)(broadcastEncrypted),
3610
+ input_nonce: (0, import_tweetnacl_util.encodeBase64)(broadcastNonce),
3611
+ input_signature: (0, import_tweetnacl_util.encodeBase64)(broadcastSig),
3418
3612
  priority: options.priority,
3419
3613
  max_agents: options.maxAgents,
3420
3614
  min_trust_level: options.minTrustLevel,
@@ -3484,16 +3678,30 @@ var VoidlyAgent = class _VoidlyAgent {
3484
3678
  // ─── Memory Store ──────────────────────────────────────────────────────────
3485
3679
  /**
3486
3680
  * Store an encrypted key-value pair in persistent memory.
3487
- * Values are encrypted with a key derived from your API key — only you can read them.
3681
+ * Values are encrypted CLIENT-SIDE with nacl.secretbox before sending to relay.
3682
+ * The relay never sees plaintext values — true E2E encrypted storage.
3488
3683
  */
3489
3684
  async memorySet(namespace, key, value, options) {
3685
+ const valueStr = JSON.stringify(value);
3686
+ const valueBytes = (0, import_tweetnacl_util.decodeUTF8)(valueStr);
3687
+ const memNonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
3688
+ const memKeyInput = new Uint8Array([...this.encryptionKeyPair.secretKey, 77, 69, 77]);
3689
+ let memKey;
3690
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
3691
+ memKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", memKeyInput));
3692
+ } else {
3693
+ const { createHash } = await import("crypto");
3694
+ memKey = new Uint8Array(createHash("sha256").update(Buffer.from(memKeyInput)).digest());
3695
+ }
3696
+ const encryptedValue = import_tweetnacl.default.secretbox(valueBytes, memNonce, memKey);
3490
3697
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
3491
3698
  method: "PUT",
3492
3699
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3493
3700
  body: JSON.stringify({
3494
- value,
3495
- value_type: options?.valueType || (typeof value === "object" ? "json" : typeof value),
3496
- ttl: options?.ttl
3701
+ value: (0, import_tweetnacl_util.encodeBase64)(encryptedValue),
3702
+ value_type: `encrypted:${options?.valueType || (typeof value === "object" ? "json" : typeof value)}`,
3703
+ ttl: options?.ttl,
3704
+ client_nonce: (0, import_tweetnacl_util.encodeBase64)(memNonce)
3497
3705
  })
3498
3706
  });
3499
3707
  if (!res.ok) throw new Error(`Memory set failed: ${res.status} ${await res.text()}`);
@@ -3501,7 +3709,7 @@ var VoidlyAgent = class _VoidlyAgent {
3501
3709
  }
3502
3710
  /**
3503
3711
  * Retrieve a value from persistent memory.
3504
- * Decrypted server-side using your API key derivation.
3712
+ * Decrypted CLIENT-SIDE relay never sees plaintext.
3505
3713
  */
3506
3714
  async memoryGet(namespace, key) {
3507
3715
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
@@ -3509,7 +3717,28 @@ var VoidlyAgent = class _VoidlyAgent {
3509
3717
  });
3510
3718
  if (res.status === 404) return null;
3511
3719
  if (!res.ok) throw new Error(`Memory get failed: ${res.status} ${await res.text()}`);
3512
- return res.json();
3720
+ const data = await res.json();
3721
+ if (data.value_type?.startsWith("encrypted:") && data.client_nonce) {
3722
+ try {
3723
+ const memKeyInput = new Uint8Array([...this.encryptionKeyPair.secretKey, 77, 69, 77]);
3724
+ let memKey;
3725
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
3726
+ memKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", memKeyInput));
3727
+ } else {
3728
+ const { createHash } = await import("crypto");
3729
+ memKey = new Uint8Array(createHash("sha256").update(Buffer.from(memKeyInput)).digest());
3730
+ }
3731
+ const encBytes = (0, import_tweetnacl_util.decodeBase64)(data.value);
3732
+ const memNonce = (0, import_tweetnacl_util.decodeBase64)(data.client_nonce);
3733
+ const plain = import_tweetnacl.default.secretbox.open(encBytes, memNonce, memKey);
3734
+ if (plain) {
3735
+ data.value = JSON.parse((0, import_tweetnacl_util.encodeUTF8)(plain));
3736
+ data.value_type = data.value_type.replace("encrypted:", "");
3737
+ }
3738
+ } catch {
3739
+ }
3740
+ }
3741
+ return data;
3513
3742
  }
3514
3743
  /**
3515
3744
  * Delete a key from persistent memory.
@@ -3978,31 +4207,40 @@ var VoidlyAgent = class _VoidlyAgent {
3978
4207
  "Approximate message size (even with padding, bounded to power-of-2)"
3979
4208
  ],
3980
4209
  relayCannotSee: [
3981
- "Message content (E2E encrypted with NaCl box)",
4210
+ "Message content (E2E encrypted \u2014 nacl.secretbox with ratchet-derived per-message keys)",
3982
4211
  "Private keys (generated and stored client-side only)",
3983
- "Memory values (encrypted with key derived from your API key)",
3984
- "Attestation content (signed but readable by anyone with the attestation)",
4212
+ "Memory values (encrypted CLIENT-SIDE with nacl.secretbox before relay storage)",
4213
+ "Past message keys (hash ratchet provides forward secrecy \u2014 old keys are deleted)",
3985
4214
  ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
3986
4215
  ],
3987
4216
  protections: [
4217
+ "Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted",
3988
4218
  "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
3989
- "Ed25519 signatures on every message",
4219
+ "Ed25519 signatures on every message (envelope + ciphertext hash)",
3990
4220
  "TOFU key pinning (MitM detection on key change)",
4221
+ "Client-side memory encryption (relay never sees plaintext values)",
4222
+ "Protocol version header (deterministic padding/sealing detection, no heuristics)",
4223
+ "Identity cache (reduced key lookups, 5-min TTL)",
4224
+ "Message deduplication (track seen message IDs)",
4225
+ "Request validation (fromCredentials validates key sizes and format)",
3991
4226
  ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
3992
4227
  ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
4228
+ ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
3993
4229
  ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
3994
4230
  "Auto-retry with exponential backoff",
3995
- "Offline message queue"
4231
+ "Offline message queue",
4232
+ "did:key interoperability (W3C standard DID format)"
3996
4233
  ],
3997
4234
  gaps: [
3998
- "No forward secrecy \u2014 same keypair for all messages (compromise exposes ALL history)",
3999
- "No post-quantum protection \u2014 vulnerable to harvest-now-decrypt-later",
4235
+ "No DH ratchet yet \u2014 hash ratchet only (no post-compromise recovery, planned for v3)",
4236
+ "No post-quantum protection \u2014 vulnerable to harvest-now-decrypt-later (planned for v3)",
4000
4237
  "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
4001
4238
  "Metadata (who, when, thread structure) visible to relay operator",
4002
4239
  "Single relay architecture (no onion routing, no mix network)",
4003
4240
  "Ed25519 signatures are non-repudiable (no deniable messaging)",
4004
4241
  "No async key agreement (no X3DH prekeys)",
4005
- "Polling-based (no WebSocket real-time transport)"
4242
+ "Polling-based (no WebSocket real-time transport)",
4243
+ "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)"
4006
4244
  ]
4007
4245
  };
4008
4246
  }
@@ -4028,6 +4266,9 @@ var Conversation = class {
4028
4266
  replyTo: this._lastMessageId || void 0
4029
4267
  });
4030
4268
  this._lastMessageId = result.id;
4269
+ if (this._messageHistory.length >= 1e3) {
4270
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4271
+ }
4031
4272
  this._messageHistory.push({
4032
4273
  id: result.id,
4033
4274
  from: this.agent.did,
@@ -4102,14 +4343,23 @@ var Conversation = class {
4102
4343
  async waitForReply(timeoutMs = 3e4) {
4103
4344
  return new Promise((resolve, reject) => {
4104
4345
  let resolved = false;
4346
+ let pollTimer = null;
4347
+ const cleanup = () => {
4348
+ resolved = true;
4349
+ if (pollTimer) {
4350
+ clearTimeout(pollTimer);
4351
+ pollTimer = null;
4352
+ }
4353
+ };
4105
4354
  const timeout = setTimeout(() => {
4106
4355
  if (!resolved) {
4107
- resolved = true;
4356
+ cleanup();
4108
4357
  reject(new Error(`No reply received within ${timeoutMs}ms`));
4109
4358
  }
4110
4359
  }, timeoutMs);
4111
4360
  const check = async () => {
4112
- while (!resolved) {
4361
+ if (resolved) return;
4362
+ try {
4113
4363
  const messages = await this.agent.receive({
4114
4364
  from: this.peerDid,
4115
4365
  threadId: this.threadId,
@@ -4117,9 +4367,12 @@ var Conversation = class {
4117
4367
  limit: 1
4118
4368
  });
4119
4369
  if (messages.length > 0 && !resolved) {
4120
- resolved = true;
4121
4370
  clearTimeout(timeout);
4371
+ cleanup();
4122
4372
  const msg = messages[0];
4373
+ if (this._messageHistory.length >= 1e3) {
4374
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4375
+ }
4123
4376
  this._messageHistory.push({
4124
4377
  id: msg.id,
4125
4378
  from: msg.from,
@@ -4134,16 +4387,19 @@ var Conversation = class {
4134
4387
  resolve(msg);
4135
4388
  return;
4136
4389
  }
4137
- await new Promise((r) => setTimeout(r, 1500));
4390
+ } catch (err) {
4391
+ if (!resolved) {
4392
+ clearTimeout(timeout);
4393
+ cleanup();
4394
+ reject(err instanceof Error ? err : new Error(String(err)));
4395
+ return;
4396
+ }
4138
4397
  }
4139
- };
4140
- check().catch((err) => {
4141
4398
  if (!resolved) {
4142
- resolved = true;
4143
- clearTimeout(timeout);
4144
- reject(err);
4399
+ pollTimer = setTimeout(check, 1500);
4145
4400
  }
4146
- });
4401
+ };
4402
+ check();
4147
4403
  });
4148
4404
  }
4149
4405
  /**
package/dist/index.mjs CHANGED
@@ -2394,6 +2394,38 @@ function unsealEnvelope(plaintext) {
2394
2394
  return null;
2395
2395
  }
2396
2396
  }
2397
+ var PROTO_MARKER = 86;
2398
+ var FLAG_PADDED = 1;
2399
+ var FLAG_SEALED = 2;
2400
+ var FLAG_RATCHET = 4;
2401
+ function makeProtoHeader(flags, ratchetStep2) {
2402
+ return new Uint8Array([PROTO_MARKER, flags, ratchetStep2 >> 8 & 255, ratchetStep2 & 255]);
2403
+ }
2404
+ function parseProtoHeader(data) {
2405
+ if (data.length < 4 || data[0] !== PROTO_MARKER) return null;
2406
+ return {
2407
+ flags: data[1],
2408
+ ratchetStep: data[2] << 8 | data[3],
2409
+ content: data.slice(4)
2410
+ };
2411
+ }
2412
+ var MAX_SKIP = 200;
2413
+ var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
2414
+ function toBase58(bytes) {
2415
+ if (bytes.length === 0) return "1";
2416
+ let result = "";
2417
+ let num = BigInt("0x" + Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""));
2418
+ while (num > 0n) {
2419
+ const remainder = num % 58n;
2420
+ num = num / 58n;
2421
+ result = BASE58_ALPHABET[Number(remainder)] + result;
2422
+ }
2423
+ for (const byte of bytes) {
2424
+ if (byte === 0) result = "1" + result;
2425
+ else break;
2426
+ }
2427
+ return result || "1";
2428
+ }
2397
2429
  var VoidlyAgent = class _VoidlyAgent {
2398
2430
  constructor(identity, config) {
2399
2431
  this._pinnedDids = /* @__PURE__ */ new Set();
@@ -2401,6 +2433,9 @@ var VoidlyAgent = class _VoidlyAgent {
2401
2433
  this._conversations = /* @__PURE__ */ new Map();
2402
2434
  this._offlineQueue = [];
2403
2435
  this._ratchetStates = /* @__PURE__ */ new Map();
2436
+ this._identityCache = /* @__PURE__ */ new Map();
2437
+ this._seenMessageIds = /* @__PURE__ */ new Set();
2438
+ this._decryptFailCount = 0;
2404
2439
  this.did = identity.did;
2405
2440
  this.apiKey = identity.apiKey;
2406
2441
  this.signingKeyPair = identity.signingKeyPair;
@@ -2411,6 +2446,8 @@ var VoidlyAgent = class _VoidlyAgent {
2411
2446
  this.fallbackRelays = config?.fallbackRelays || [];
2412
2447
  this.paddingEnabled = config?.padding !== false;
2413
2448
  this.sealedSender = config?.sealedSender || false;
2449
+ this.requireSignatures = config?.requireSignatures || false;
2450
+ this.timeout = config?.timeout ?? 3e4;
2414
2451
  }
2415
2452
  // ─── Factory Methods ────────────────────────────────────────────────────────
2416
2453
  /**
@@ -2449,8 +2486,29 @@ var VoidlyAgent = class _VoidlyAgent {
2449
2486
  * Use this to resume an agent across sessions.
2450
2487
  */
2451
2488
  static fromCredentials(creds, config) {
2452
- const signingSecret = (0, import_tweetnacl_util.decodeBase64)(creds.signingSecretKey);
2453
- const encryptionSecret = (0, import_tweetnacl_util.decodeBase64)(creds.encryptionSecretKey);
2489
+ if (!creds.did || !creds.did.startsWith("did:")) {
2490
+ throw new Error('Invalid credentials: did must start with "did:"');
2491
+ }
2492
+ if (!creds.apiKey || creds.apiKey.length < 8) {
2493
+ throw new Error("Invalid credentials: apiKey is missing or too short");
2494
+ }
2495
+ if (!creds.signingSecretKey || !creds.encryptionSecretKey) {
2496
+ throw new Error("Invalid credentials: secret keys are required");
2497
+ }
2498
+ let signingSecret;
2499
+ let encryptionSecret;
2500
+ try {
2501
+ signingSecret = (0, import_tweetnacl_util.decodeBase64)(creds.signingSecretKey);
2502
+ encryptionSecret = (0, import_tweetnacl_util.decodeBase64)(creds.encryptionSecretKey);
2503
+ } catch {
2504
+ throw new Error("Invalid credentials: secret keys must be valid base64");
2505
+ }
2506
+ if (signingSecret.length !== 64) {
2507
+ throw new Error(`Invalid credentials: signing key must be 64 bytes, got ${signingSecret.length}`);
2508
+ }
2509
+ if (encryptionSecret.length !== 32) {
2510
+ throw new Error(`Invalid credentials: encryption key must be 32 bytes, got ${encryptionSecret.length}`);
2511
+ }
2454
2512
  return new _VoidlyAgent({
2455
2513
  did: creds.did,
2456
2514
  apiKey: creds.apiKey,
@@ -2475,6 +2533,25 @@ var VoidlyAgent = class _VoidlyAgent {
2475
2533
  encryptionPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.encryptionKeyPair.publicKey)
2476
2534
  };
2477
2535
  }
2536
+ /**
2537
+ * Get the number of messages that failed to decrypt.
2538
+ * Useful for detecting key mismatches, attacks, or corruption.
2539
+ */
2540
+ get decryptFailCount() {
2541
+ return this._decryptFailCount;
2542
+ }
2543
+ /**
2544
+ * Generate a did:key identifier from this agent's Ed25519 signing key.
2545
+ * did:key is a W3C standard — interoperable across systems.
2546
+ * Format: did:key:z6Mk{base58-multicodec-ed25519-pubkey}
2547
+ */
2548
+ get didKey() {
2549
+ const multicodec = new Uint8Array(2 + this.signingKeyPair.publicKey.length);
2550
+ multicodec[0] = 237;
2551
+ multicodec[1] = 1;
2552
+ multicodec.set(this.signingKeyPair.publicKey, 2);
2553
+ return `did:key:z${toBase58(multicodec)}`;
2554
+ }
2478
2555
  // ─── Messaging ──────────────────────────────────────────────────────────────
2479
2556
  /**
2480
2557
  * Send an E2E encrypted message with hardened security.
@@ -2505,29 +2582,39 @@ var VoidlyAgent = class _VoidlyAgent {
2505
2582
  if (useSealed) {
2506
2583
  plaintext = sealEnvelope(this.did, message);
2507
2584
  }
2508
- let messageBytes;
2585
+ let contentBytes;
2509
2586
  if (usePadding) {
2510
- messageBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2587
+ contentBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2511
2588
  } else {
2512
- messageBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2589
+ contentBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2513
2590
  }
2514
2591
  const pairId = `${this.did}:${recipientDid}`;
2515
- const ratchetState = this._ratchetStates.get(pairId);
2516
- let ratchetStep_n = 0;
2517
- if (ratchetState) {
2518
- const { nextChainKey, messageKey } = await ratchetStep(ratchetState.chainKey);
2519
- ratchetState.chainKey = nextChainKey;
2520
- ratchetState.step++;
2521
- ratchetStep_n = ratchetState.step;
2522
- } else {
2592
+ let state = this._ratchetStates.get(pairId);
2593
+ if (!state) {
2523
2594
  const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
2524
- this._ratchetStates.set(pairId, {
2525
- chainKey: sharedSecret,
2526
- step: 0
2527
- });
2595
+ state = {
2596
+ sendChainKey: sharedSecret,
2597
+ sendStep: 0,
2598
+ recvChainKey: sharedSecret,
2599
+ // Will be synced on first receive
2600
+ recvStep: 0,
2601
+ skippedKeys: /* @__PURE__ */ new Map()
2602
+ };
2603
+ this._ratchetStates.set(pairId, state);
2528
2604
  }
2529
- const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
2530
- const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
2605
+ const { nextChainKey, messageKey } = await ratchetStep(state.sendChainKey);
2606
+ state.sendChainKey = nextChainKey;
2607
+ state.sendStep++;
2608
+ const currentStep = state.sendStep;
2609
+ let flags = FLAG_RATCHET;
2610
+ if (usePadding) flags |= FLAG_PADDED;
2611
+ if (useSealed) flags |= FLAG_SEALED;
2612
+ const header = makeProtoHeader(flags, currentStep);
2613
+ const messageBytes = new Uint8Array(header.length + contentBytes.length);
2614
+ messageBytes.set(header, 0);
2615
+ messageBytes.set(contentBytes, header.length);
2616
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
2617
+ const ciphertext = import_tweetnacl.default.secretbox(messageBytes, nonce, messageKey);
2531
2618
  if (!ciphertext) {
2532
2619
  throw new Error("Encryption failed");
2533
2620
  }
@@ -2536,7 +2623,8 @@ var VoidlyAgent = class _VoidlyAgent {
2536
2623
  to: recipientDid,
2537
2624
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2538
2625
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
2539
- ciphertext_hash: await sha256((0, import_tweetnacl_util.encodeBase64)(ciphertext))
2626
+ ciphertext_hash: await sha256((0, import_tweetnacl_util.encodeBase64)(ciphertext)),
2627
+ ratchet_step: currentStep
2540
2628
  });
2541
2629
  const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
2542
2630
  const payload = {
@@ -2612,13 +2700,85 @@ var VoidlyAgent = class _VoidlyAgent {
2612
2700
  const decrypted = [];
2613
2701
  for (const msg of data.messages) {
2614
2702
  try {
2703
+ if (this._seenMessageIds.has(msg.id)) continue;
2615
2704
  const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
2616
2705
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
2617
2706
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
2618
- const rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2619
- if (!rawPlaintext) continue;
2707
+ let rawPlaintext = null;
2708
+ let envelopeRatchetStep = 0;
2709
+ if (msg.envelope) {
2710
+ try {
2711
+ const env = JSON.parse(msg.envelope);
2712
+ if (typeof env.ratchet_step === "number") {
2713
+ envelopeRatchetStep = env.ratchet_step;
2714
+ }
2715
+ } catch {
2716
+ }
2717
+ }
2718
+ if (envelopeRatchetStep > 0) {
2719
+ const pairId = `${msg.from}:${this.did}`;
2720
+ let state = this._ratchetStates.get(pairId);
2721
+ if (!state) {
2722
+ const sharedSecret = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
2723
+ state = {
2724
+ sendChainKey: sharedSecret,
2725
+ // Our sending chain to this peer
2726
+ sendStep: 0,
2727
+ recvChainKey: sharedSecret,
2728
+ // Their sending chain (our receiving)
2729
+ recvStep: 0,
2730
+ skippedKeys: /* @__PURE__ */ new Map()
2731
+ };
2732
+ this._ratchetStates.set(pairId, state);
2733
+ }
2734
+ const targetStep = envelopeRatchetStep;
2735
+ if (state.skippedKeys.has(targetStep)) {
2736
+ const mk = state.skippedKeys.get(targetStep);
2737
+ rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
2738
+ state.skippedKeys.delete(targetStep);
2739
+ } else if (targetStep > state.recvStep) {
2740
+ const skip = targetStep - state.recvStep;
2741
+ if (skip > MAX_SKIP) {
2742
+ rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2743
+ } else {
2744
+ let ck = state.recvChainKey;
2745
+ for (let i = state.recvStep + 1; i < targetStep; i++) {
2746
+ const { nextChainKey: nextChainKey2, messageKey: skippedMk } = await ratchetStep(ck);
2747
+ state.skippedKeys.set(i, skippedMk);
2748
+ ck = nextChainKey2;
2749
+ if (state.skippedKeys.size > MAX_SKIP) {
2750
+ const oldest = state.skippedKeys.keys().next().value;
2751
+ if (oldest !== void 0) state.skippedKeys.delete(oldest);
2752
+ }
2753
+ }
2754
+ const { nextChainKey, messageKey } = await ratchetStep(ck);
2755
+ state.recvChainKey = nextChainKey;
2756
+ state.recvStep = targetStep;
2757
+ rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, messageKey);
2758
+ }
2759
+ }
2760
+ if (!rawPlaintext) {
2761
+ rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2762
+ }
2763
+ } else {
2764
+ rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2765
+ }
2766
+ if (!rawPlaintext) {
2767
+ this._decryptFailCount++;
2768
+ continue;
2769
+ }
2620
2770
  let plaintextBytes = rawPlaintext;
2621
- if (rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
2771
+ let wasPadded = false;
2772
+ let wasSealed = false;
2773
+ const proto = parseProtoHeader(rawPlaintext);
2774
+ if (proto) {
2775
+ wasPadded = !!(proto.flags & FLAG_PADDED);
2776
+ wasSealed = !!(proto.flags & FLAG_SEALED);
2777
+ plaintextBytes = proto.content;
2778
+ }
2779
+ if (wasPadded) {
2780
+ plaintextBytes = unpadMessage(plaintextBytes);
2781
+ } else if (!proto && rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
2622
2782
  const unpadded = unpadMessage(rawPlaintext);
2623
2783
  if (unpadded.length < rawPlaintext.length) {
2624
2784
  plaintextBytes = unpadded;
@@ -2626,10 +2786,12 @@ var VoidlyAgent = class _VoidlyAgent {
2626
2786
  }
2627
2787
  let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
2628
2788
  let senderDid = msg.from;
2629
- const unsealed = unsealEnvelope(content);
2630
- if (unsealed) {
2631
- content = unsealed.msg;
2632
- senderDid = unsealed.from;
2789
+ if (wasSealed || !proto) {
2790
+ const unsealed = unsealEnvelope(content);
2791
+ if (unsealed) {
2792
+ content = unsealed.msg;
2793
+ senderDid = unsealed.from;
2794
+ }
2633
2795
  }
2634
2796
  let signatureValid = false;
2635
2797
  try {
@@ -2650,6 +2812,15 @@ var VoidlyAgent = class _VoidlyAgent {
2650
2812
  } catch {
2651
2813
  signatureValid = false;
2652
2814
  }
2815
+ if (this.requireSignatures && !signatureValid) {
2816
+ this._decryptFailCount++;
2817
+ continue;
2818
+ }
2819
+ this._seenMessageIds.add(msg.id);
2820
+ if (this._seenMessageIds.size > 1e4) {
2821
+ const first = this._seenMessageIds.values().next().value;
2822
+ if (first !== void 0) this._seenMessageIds.delete(first);
2823
+ }
2653
2824
  decrypted.push({
2654
2825
  id: msg.id,
2655
2826
  from: senderDid,
@@ -2664,6 +2835,7 @@ var VoidlyAgent = class _VoidlyAgent {
2664
2835
  expiresAt: msg.expires_at
2665
2836
  });
2666
2837
  } catch {
2838
+ this._decryptFailCount++;
2667
2839
  }
2668
2840
  }
2669
2841
  return decrypted;
@@ -2714,9 +2886,19 @@ var VoidlyAgent = class _VoidlyAgent {
2714
2886
  * Look up an agent's public profile and keys.
2715
2887
  */
2716
2888
  async getIdentity(did) {
2889
+ const cached = this._identityCache.get(did);
2890
+ if (cached && Date.now() - cached.cachedAt < 3e5) {
2891
+ return cached.profile;
2892
+ }
2717
2893
  const res = await fetch(`${this.baseUrl}/v1/agent/identity/${did}`);
2718
2894
  if (!res.ok) return null;
2719
- return await res.json();
2895
+ const profile = await res.json();
2896
+ this._identityCache.set(did, { profile, cachedAt: Date.now() });
2897
+ if (this._identityCache.size > 500) {
2898
+ const oldest = this._identityCache.keys().next().value;
2899
+ if (oldest !== void 0) this._identityCache.delete(oldest);
2900
+ }
2901
+ return profile;
2720
2902
  }
2721
2903
  /**
2722
2904
  * Search for agents by name or capability.
@@ -2776,10 +2958,14 @@ var VoidlyAgent = class _VoidlyAgent {
2776
2958
  * Delete a webhook.
2777
2959
  */
2778
2960
  async deleteWebhook(webhookId) {
2779
- await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2961
+ const res = await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2780
2962
  method: "DELETE",
2781
2963
  headers: { "X-Agent-Key": this.apiKey }
2782
2964
  });
2965
+ if (!res.ok) {
2966
+ const err = await res.json().catch(() => ({}));
2967
+ throw new Error(`Webhook delete failed: ${err.error || res.statusText}`);
2968
+ }
2783
2969
  }
2784
2970
  /**
2785
2971
  * Verify a webhook payload signature (for use in your webhook handler).
@@ -3397,13 +3583,21 @@ var VoidlyAgent = class _VoidlyAgent {
3397
3583
  * The relay finds matching agents and creates individual tasks for each.
3398
3584
  */
3399
3585
  async broadcastTask(options) {
3586
+ const inputBytes = (0, import_tweetnacl_util.decodeUTF8)(options.input);
3587
+ const broadcastNonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
3588
+ const broadcastEncrypted = import_tweetnacl.default.secretbox(inputBytes, broadcastNonce, import_tweetnacl.default.box.before(
3589
+ this.encryptionKeyPair.publicKey,
3590
+ this.encryptionKeyPair.secretKey
3591
+ ));
3592
+ const broadcastSig = import_tweetnacl.default.sign.detached(inputBytes, this.signingKeyPair.secretKey);
3400
3593
  const res = await fetch(`${this.baseUrl}/v1/agent/tasks/broadcast`, {
3401
3594
  method: "POST",
3402
3595
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3403
3596
  body: JSON.stringify({
3404
3597
  capability: options.capability,
3405
- encrypted_input: (0, import_tweetnacl_util.encodeBase64)((0, import_tweetnacl_util.decodeUTF8)(options.input)),
3406
- input_nonce: (0, import_tweetnacl_util.encodeBase64)(import_tweetnacl.default.randomBytes(24)),
3598
+ encrypted_input: (0, import_tweetnacl_util.encodeBase64)(broadcastEncrypted),
3599
+ input_nonce: (0, import_tweetnacl_util.encodeBase64)(broadcastNonce),
3600
+ input_signature: (0, import_tweetnacl_util.encodeBase64)(broadcastSig),
3407
3601
  priority: options.priority,
3408
3602
  max_agents: options.maxAgents,
3409
3603
  min_trust_level: options.minTrustLevel,
@@ -3473,16 +3667,30 @@ var VoidlyAgent = class _VoidlyAgent {
3473
3667
  // ─── Memory Store ──────────────────────────────────────────────────────────
3474
3668
  /**
3475
3669
  * Store an encrypted key-value pair in persistent memory.
3476
- * Values are encrypted with a key derived from your API key — only you can read them.
3670
+ * Values are encrypted CLIENT-SIDE with nacl.secretbox before sending to relay.
3671
+ * The relay never sees plaintext values — true E2E encrypted storage.
3477
3672
  */
3478
3673
  async memorySet(namespace, key, value, options) {
3674
+ const valueStr = JSON.stringify(value);
3675
+ const valueBytes = (0, import_tweetnacl_util.decodeUTF8)(valueStr);
3676
+ const memNonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
3677
+ const memKeyInput = new Uint8Array([...this.encryptionKeyPair.secretKey, 77, 69, 77]);
3678
+ let memKey;
3679
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
3680
+ memKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", memKeyInput));
3681
+ } else {
3682
+ const { createHash } = await import("crypto");
3683
+ memKey = new Uint8Array(createHash("sha256").update(Buffer.from(memKeyInput)).digest());
3684
+ }
3685
+ const encryptedValue = import_tweetnacl.default.secretbox(valueBytes, memNonce, memKey);
3479
3686
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
3480
3687
  method: "PUT",
3481
3688
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3482
3689
  body: JSON.stringify({
3483
- value,
3484
- value_type: options?.valueType || (typeof value === "object" ? "json" : typeof value),
3485
- ttl: options?.ttl
3690
+ value: (0, import_tweetnacl_util.encodeBase64)(encryptedValue),
3691
+ value_type: `encrypted:${options?.valueType || (typeof value === "object" ? "json" : typeof value)}`,
3692
+ ttl: options?.ttl,
3693
+ client_nonce: (0, import_tweetnacl_util.encodeBase64)(memNonce)
3486
3694
  })
3487
3695
  });
3488
3696
  if (!res.ok) throw new Error(`Memory set failed: ${res.status} ${await res.text()}`);
@@ -3490,7 +3698,7 @@ var VoidlyAgent = class _VoidlyAgent {
3490
3698
  }
3491
3699
  /**
3492
3700
  * Retrieve a value from persistent memory.
3493
- * Decrypted server-side using your API key derivation.
3701
+ * Decrypted CLIENT-SIDE relay never sees plaintext.
3494
3702
  */
3495
3703
  async memoryGet(namespace, key) {
3496
3704
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
@@ -3498,7 +3706,28 @@ var VoidlyAgent = class _VoidlyAgent {
3498
3706
  });
3499
3707
  if (res.status === 404) return null;
3500
3708
  if (!res.ok) throw new Error(`Memory get failed: ${res.status} ${await res.text()}`);
3501
- return res.json();
3709
+ const data = await res.json();
3710
+ if (data.value_type?.startsWith("encrypted:") && data.client_nonce) {
3711
+ try {
3712
+ const memKeyInput = new Uint8Array([...this.encryptionKeyPair.secretKey, 77, 69, 77]);
3713
+ let memKey;
3714
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
3715
+ memKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", memKeyInput));
3716
+ } else {
3717
+ const { createHash } = await import("crypto");
3718
+ memKey = new Uint8Array(createHash("sha256").update(Buffer.from(memKeyInput)).digest());
3719
+ }
3720
+ const encBytes = (0, import_tweetnacl_util.decodeBase64)(data.value);
3721
+ const memNonce = (0, import_tweetnacl_util.decodeBase64)(data.client_nonce);
3722
+ const plain = import_tweetnacl.default.secretbox.open(encBytes, memNonce, memKey);
3723
+ if (plain) {
3724
+ data.value = JSON.parse((0, import_tweetnacl_util.encodeUTF8)(plain));
3725
+ data.value_type = data.value_type.replace("encrypted:", "");
3726
+ }
3727
+ } catch {
3728
+ }
3729
+ }
3730
+ return data;
3502
3731
  }
3503
3732
  /**
3504
3733
  * Delete a key from persistent memory.
@@ -3967,31 +4196,40 @@ var VoidlyAgent = class _VoidlyAgent {
3967
4196
  "Approximate message size (even with padding, bounded to power-of-2)"
3968
4197
  ],
3969
4198
  relayCannotSee: [
3970
- "Message content (E2E encrypted with NaCl box)",
4199
+ "Message content (E2E encrypted \u2014 nacl.secretbox with ratchet-derived per-message keys)",
3971
4200
  "Private keys (generated and stored client-side only)",
3972
- "Memory values (encrypted with key derived from your API key)",
3973
- "Attestation content (signed but readable by anyone with the attestation)",
4201
+ "Memory values (encrypted CLIENT-SIDE with nacl.secretbox before relay storage)",
4202
+ "Past message keys (hash ratchet provides forward secrecy \u2014 old keys are deleted)",
3974
4203
  ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
3975
4204
  ],
3976
4205
  protections: [
4206
+ "Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted",
3977
4207
  "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
3978
- "Ed25519 signatures on every message",
4208
+ "Ed25519 signatures on every message (envelope + ciphertext hash)",
3979
4209
  "TOFU key pinning (MitM detection on key change)",
4210
+ "Client-side memory encryption (relay never sees plaintext values)",
4211
+ "Protocol version header (deterministic padding/sealing detection, no heuristics)",
4212
+ "Identity cache (reduced key lookups, 5-min TTL)",
4213
+ "Message deduplication (track seen message IDs)",
4214
+ "Request validation (fromCredentials validates key sizes and format)",
3980
4215
  ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
3981
4216
  ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
4217
+ ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
3982
4218
  ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
3983
4219
  "Auto-retry with exponential backoff",
3984
- "Offline message queue"
4220
+ "Offline message queue",
4221
+ "did:key interoperability (W3C standard DID format)"
3985
4222
  ],
3986
4223
  gaps: [
3987
- "No forward secrecy \u2014 same keypair for all messages (compromise exposes ALL history)",
3988
- "No post-quantum protection \u2014 vulnerable to harvest-now-decrypt-later",
4224
+ "No DH ratchet yet \u2014 hash ratchet only (no post-compromise recovery, planned for v3)",
4225
+ "No post-quantum protection \u2014 vulnerable to harvest-now-decrypt-later (planned for v3)",
3989
4226
  "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
3990
4227
  "Metadata (who, when, thread structure) visible to relay operator",
3991
4228
  "Single relay architecture (no onion routing, no mix network)",
3992
4229
  "Ed25519 signatures are non-repudiable (no deniable messaging)",
3993
4230
  "No async key agreement (no X3DH prekeys)",
3994
- "Polling-based (no WebSocket real-time transport)"
4231
+ "Polling-based (no WebSocket real-time transport)",
4232
+ "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)"
3995
4233
  ]
3996
4234
  };
3997
4235
  }
@@ -4017,6 +4255,9 @@ var Conversation = class {
4017
4255
  replyTo: this._lastMessageId || void 0
4018
4256
  });
4019
4257
  this._lastMessageId = result.id;
4258
+ if (this._messageHistory.length >= 1e3) {
4259
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4260
+ }
4020
4261
  this._messageHistory.push({
4021
4262
  id: result.id,
4022
4263
  from: this.agent.did,
@@ -4091,14 +4332,23 @@ var Conversation = class {
4091
4332
  async waitForReply(timeoutMs = 3e4) {
4092
4333
  return new Promise((resolve, reject) => {
4093
4334
  let resolved = false;
4335
+ let pollTimer = null;
4336
+ const cleanup = () => {
4337
+ resolved = true;
4338
+ if (pollTimer) {
4339
+ clearTimeout(pollTimer);
4340
+ pollTimer = null;
4341
+ }
4342
+ };
4094
4343
  const timeout = setTimeout(() => {
4095
4344
  if (!resolved) {
4096
- resolved = true;
4345
+ cleanup();
4097
4346
  reject(new Error(`No reply received within ${timeoutMs}ms`));
4098
4347
  }
4099
4348
  }, timeoutMs);
4100
4349
  const check = async () => {
4101
- while (!resolved) {
4350
+ if (resolved) return;
4351
+ try {
4102
4352
  const messages = await this.agent.receive({
4103
4353
  from: this.peerDid,
4104
4354
  threadId: this.threadId,
@@ -4106,9 +4356,12 @@ var Conversation = class {
4106
4356
  limit: 1
4107
4357
  });
4108
4358
  if (messages.length > 0 && !resolved) {
4109
- resolved = true;
4110
4359
  clearTimeout(timeout);
4360
+ cleanup();
4111
4361
  const msg = messages[0];
4362
+ if (this._messageHistory.length >= 1e3) {
4363
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4364
+ }
4112
4365
  this._messageHistory.push({
4113
4366
  id: msg.id,
4114
4367
  from: msg.from,
@@ -4123,16 +4376,19 @@ var Conversation = class {
4123
4376
  resolve(msg);
4124
4377
  return;
4125
4378
  }
4126
- await new Promise((r) => setTimeout(r, 1500));
4379
+ } catch (err) {
4380
+ if (!resolved) {
4381
+ clearTimeout(timeout);
4382
+ cleanup();
4383
+ reject(err instanceof Error ? err : new Error(String(err)));
4384
+ return;
4385
+ }
4127
4386
  }
4128
- };
4129
- check().catch((err) => {
4130
4387
  if (!resolved) {
4131
- resolved = true;
4132
- clearTimeout(timeout);
4133
- reject(err);
4388
+ pollTimer = setTimeout(check, 1500);
4134
4389
  }
4135
- });
4390
+ };
4391
+ check();
4136
4392
  });
4137
4393
  }
4138
4394
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@voidly/agent-sdk",
3
- "version": "2.0.0",
4
- "description": "E2E encrypted agent-to-agent communication SDK — padding, sealed sender, multi-relay fallback",
3
+ "version": "2.1.0",
4
+ "description": "E2E encrypted agent-to-agent communication SDK — forward secrecy, padding, sealed sender, client-side memory encryption",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",