@voidly/agent-sdk 1.9.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.mjs CHANGED
@@ -2335,11 +2335,107 @@ async function sha256(data) {
2335
2335
  const { createHash } = await import("crypto");
2336
2336
  return createHash("sha256").update(data).digest("hex");
2337
2337
  }
2338
+ function padMessage(content) {
2339
+ const contentLen = content.length;
2340
+ const totalLen = Math.max(256, nextPowerOf2(contentLen + 2));
2341
+ const padded = new Uint8Array(totalLen);
2342
+ padded[0] = contentLen >> 8 & 255;
2343
+ padded[1] = contentLen & 255;
2344
+ padded.set(content, 2);
2345
+ const randomPad = import_tweetnacl.default.randomBytes(totalLen - contentLen - 2);
2346
+ padded.set(randomPad, contentLen + 2);
2347
+ return padded;
2348
+ }
2349
+ function unpadMessage(padded) {
2350
+ if (padded.length < 2) return padded;
2351
+ const contentLen = padded[0] << 8 | padded[1];
2352
+ if (contentLen + 2 > padded.length) return padded;
2353
+ return padded.slice(2, 2 + contentLen);
2354
+ }
2355
+ function nextPowerOf2(n) {
2356
+ let p = 1;
2357
+ while (p < n) p <<= 1;
2358
+ return p;
2359
+ }
2360
+ async function ratchetStep(chainKey) {
2361
+ const encoder = new TextEncoder();
2362
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
2363
+ const ckInput = new Uint8Array([...chainKey, 1]);
2364
+ const mkInput = new Uint8Array([...chainKey, 2]);
2365
+ const nextChainKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", ckInput));
2366
+ const messageKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", mkInput));
2367
+ return { nextChainKey: nextChainKey2, messageKey: messageKey2 };
2368
+ }
2369
+ const { createHash } = await import("crypto");
2370
+ const nextChainKey = new Uint8Array(
2371
+ createHash("sha256").update(Buffer.from([...chainKey, 1])).digest()
2372
+ );
2373
+ const messageKey = new Uint8Array(
2374
+ createHash("sha256").update(Buffer.from([...chainKey, 2])).digest()
2375
+ );
2376
+ return { nextChainKey, messageKey };
2377
+ }
2378
+ function sealEnvelope(senderDid, plaintext) {
2379
+ return JSON.stringify({
2380
+ v: 2,
2381
+ from: senderDid,
2382
+ msg: plaintext,
2383
+ ts: (/* @__PURE__ */ new Date()).toISOString()
2384
+ });
2385
+ }
2386
+ function unsealEnvelope(plaintext) {
2387
+ try {
2388
+ const parsed = JSON.parse(plaintext);
2389
+ if (parsed.v === 2 && parsed.from && parsed.msg) {
2390
+ return { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
2391
+ }
2392
+ return null;
2393
+ } catch {
2394
+ return null;
2395
+ }
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
+ }
2338
2429
  var VoidlyAgent = class _VoidlyAgent {
2339
2430
  constructor(identity, config) {
2340
2431
  this._pinnedDids = /* @__PURE__ */ new Set();
2341
2432
  this._listeners = /* @__PURE__ */ new Set();
2342
2433
  this._conversations = /* @__PURE__ */ new Map();
2434
+ this._offlineQueue = [];
2435
+ this._ratchetStates = /* @__PURE__ */ new Map();
2436
+ this._identityCache = /* @__PURE__ */ new Map();
2437
+ this._seenMessageIds = /* @__PURE__ */ new Set();
2438
+ this._decryptFailCount = 0;
2343
2439
  this.did = identity.did;
2344
2440
  this.apiKey = identity.apiKey;
2345
2441
  this.signingKeyPair = identity.signingKeyPair;
@@ -2347,6 +2443,11 @@ var VoidlyAgent = class _VoidlyAgent {
2347
2443
  this.baseUrl = config?.baseUrl || "https://api.voidly.ai";
2348
2444
  this.autoPin = config?.autoPin !== false;
2349
2445
  this.defaultRetries = config?.retries ?? 3;
2446
+ this.fallbackRelays = config?.fallbackRelays || [];
2447
+ this.paddingEnabled = config?.padding !== false;
2448
+ this.sealedSender = config?.sealedSender || false;
2449
+ this.requireSignatures = config?.requireSignatures || false;
2450
+ this.timeout = config?.timeout ?? 3e4;
2350
2451
  }
2351
2452
  // ─── Factory Methods ────────────────────────────────────────────────────────
2352
2453
  /**
@@ -2385,8 +2486,29 @@ var VoidlyAgent = class _VoidlyAgent {
2385
2486
  * Use this to resume an agent across sessions.
2386
2487
  */
2387
2488
  static fromCredentials(creds, config) {
2388
- const signingSecret = (0, import_tweetnacl_util.decodeBase64)(creds.signingSecretKey);
2389
- 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
+ }
2390
2512
  return new _VoidlyAgent({
2391
2513
  did: creds.did,
2392
2514
  apiKey: creds.apiKey,
@@ -2411,18 +2533,43 @@ var VoidlyAgent = class _VoidlyAgent {
2411
2533
  encryptionPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.encryptionKeyPair.publicKey)
2412
2534
  };
2413
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
+ }
2414
2555
  // ─── Messaging ──────────────────────────────────────────────────────────────
2415
2556
  /**
2416
- * Send an E2E encrypted message with automatic retry and transparent TOFU.
2557
+ * Send an E2E encrypted message with hardened security.
2417
2558
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
2418
2559
  *
2419
- * Features:
2560
+ * Security features:
2561
+ * - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
2562
+ * - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
2563
+ * - **Sealed sender** — optionally hide sender DID from relay metadata
2420
2564
  * - **Auto-retry** with exponential backoff on transient failures
2421
- * - **Transparent TOFU** — automatically pins recipient keys on first contact
2422
- * - **Key verification** — warns if pinned keys have changed (potential MitM)
2565
+ * - **Multi-relay fallback** — try backup relays if primary is down
2566
+ * - **Offline queue** — queue messages if all relays fail
2567
+ * - **Transparent TOFU** — auto-pin recipient keys on first contact
2423
2568
  */
2424
2569
  async send(recipientDid, message, options = {}) {
2425
2570
  const maxRetries = options.retries ?? this.defaultRetries;
2571
+ const usePadding = !options.noPadding && this.paddingEnabled;
2572
+ const useSealed = options.sealedSender ?? this.sealedSender;
2426
2573
  const profile = await this.getIdentity(recipientDid);
2427
2574
  if (!profile) {
2428
2575
  throw new Error(`Recipient ${recipientDid} not found`);
@@ -2431,9 +2578,43 @@ var VoidlyAgent = class _VoidlyAgent {
2431
2578
  await this._autoPinKeys(recipientDid);
2432
2579
  }
2433
2580
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
2434
- const messageBytes = (0, import_tweetnacl_util.decodeUTF8)(message);
2435
- const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
2436
- const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
2581
+ let plaintext = message;
2582
+ if (useSealed) {
2583
+ plaintext = sealEnvelope(this.did, message);
2584
+ }
2585
+ let contentBytes;
2586
+ if (usePadding) {
2587
+ contentBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2588
+ } else {
2589
+ contentBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2590
+ }
2591
+ const pairId = `${this.did}:${recipientDid}`;
2592
+ let state = this._ratchetStates.get(pairId);
2593
+ if (!state) {
2594
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
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);
2604
+ }
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);
2437
2618
  if (!ciphertext) {
2438
2619
  throw new Error("Encryption failed");
2439
2620
  }
@@ -2442,7 +2623,8 @@ var VoidlyAgent = class _VoidlyAgent {
2442
2623
  to: recipientDid,
2443
2624
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2444
2625
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
2445
- 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
2446
2628
  });
2447
2629
  const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
2448
2630
  const payload = {
@@ -2457,24 +2639,42 @@ var VoidlyAgent = class _VoidlyAgent {
2457
2639
  reply_to: options.replyTo,
2458
2640
  ttl: options.ttl
2459
2641
  };
2460
- const raw = await this._fetchWithRetry(
2461
- `${this.baseUrl}/v1/agent/send/encrypted`,
2462
- {
2463
- method: "POST",
2464
- headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2465
- body: JSON.stringify(payload)
2466
- },
2467
- { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2468
- );
2469
- return {
2470
- id: raw.id,
2471
- from: raw.from,
2472
- to: raw.to,
2473
- timestamp: raw.timestamp,
2474
- expiresAt: raw.expires_at || raw.expiresAt,
2475
- encrypted: raw.encrypted,
2476
- clientSide: raw.client_side || raw.clientSide
2477
- };
2642
+ const relays = [this.baseUrl, ...this.fallbackRelays];
2643
+ let lastError = null;
2644
+ for (const relay of relays) {
2645
+ try {
2646
+ const raw = await this._fetchWithRetry(
2647
+ `${relay}/v1/agent/send/encrypted`,
2648
+ {
2649
+ method: "POST",
2650
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2651
+ body: JSON.stringify(payload)
2652
+ },
2653
+ { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2654
+ );
2655
+ return {
2656
+ id: raw.id,
2657
+ from: raw.from,
2658
+ to: raw.to,
2659
+ timestamp: raw.timestamp,
2660
+ expiresAt: raw.expires_at || raw.expiresAt,
2661
+ encrypted: raw.encrypted,
2662
+ clientSide: raw.client_side || raw.clientSide
2663
+ };
2664
+ } catch (err) {
2665
+ lastError = err instanceof Error ? err : new Error(String(err));
2666
+ if (lastError.message.includes("(4")) break;
2667
+ }
2668
+ }
2669
+ if (lastError && !lastError.message.includes("(4")) {
2670
+ this._offlineQueue.push({
2671
+ recipientDid,
2672
+ message,
2673
+ options,
2674
+ timestamp: Date.now()
2675
+ });
2676
+ }
2677
+ throw lastError || new Error("Send failed");
2478
2678
  }
2479
2679
  /**
2480
2680
  * Receive and decrypt messages. Decryption happens locally.
@@ -2500,17 +2700,105 @@ var VoidlyAgent = class _VoidlyAgent {
2500
2700
  const decrypted = [];
2501
2701
  for (const msg of data.messages) {
2502
2702
  try {
2703
+ if (this._seenMessageIds.has(msg.id)) continue;
2503
2704
  const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
2504
2705
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
2505
2706
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
2506
- const plaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2507
- if (!plaintext) 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
+ }
2770
+ let plaintextBytes = rawPlaintext;
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) {
2782
+ const unpadded = unpadMessage(rawPlaintext);
2783
+ if (unpadded.length < rawPlaintext.length) {
2784
+ plaintextBytes = unpadded;
2785
+ }
2786
+ }
2787
+ let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
2788
+ let senderDid = msg.from;
2789
+ if (wasSealed || !proto) {
2790
+ const unsealed = unsealEnvelope(content);
2791
+ if (unsealed) {
2792
+ content = unsealed.msg;
2793
+ senderDid = unsealed.from;
2794
+ }
2795
+ }
2508
2796
  let signatureValid = false;
2509
2797
  try {
2510
2798
  const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
2511
2799
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
2512
2800
  const envelopeStr = msg.envelope || JSON.stringify({
2513
- from: msg.from,
2801
+ from: senderDid,
2514
2802
  to: msg.to,
2515
2803
  timestamp: msg.timestamp,
2516
2804
  nonce: msg.nonce,
@@ -2524,11 +2812,20 @@ var VoidlyAgent = class _VoidlyAgent {
2524
2812
  } catch {
2525
2813
  signatureValid = false;
2526
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
+ }
2527
2824
  decrypted.push({
2528
2825
  id: msg.id,
2529
- from: msg.from,
2826
+ from: senderDid,
2530
2827
  to: msg.to,
2531
- content: (0, import_tweetnacl_util.encodeUTF8)(plaintext),
2828
+ content,
2532
2829
  contentType: msg.content_type,
2533
2830
  messageType: msg.message_type || "text",
2534
2831
  threadId: msg.thread_id,
@@ -2538,6 +2835,7 @@ var VoidlyAgent = class _VoidlyAgent {
2538
2835
  expiresAt: msg.expires_at
2539
2836
  });
2540
2837
  } catch {
2838
+ this._decryptFailCount++;
2541
2839
  }
2542
2840
  }
2543
2841
  return decrypted;
@@ -2588,9 +2886,19 @@ var VoidlyAgent = class _VoidlyAgent {
2588
2886
  * Look up an agent's public profile and keys.
2589
2887
  */
2590
2888
  async getIdentity(did) {
2889
+ const cached = this._identityCache.get(did);
2890
+ if (cached && Date.now() - cached.cachedAt < 3e5) {
2891
+ return cached.profile;
2892
+ }
2591
2893
  const res = await fetch(`${this.baseUrl}/v1/agent/identity/${did}`);
2592
2894
  if (!res.ok) return null;
2593
- 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;
2594
2902
  }
2595
2903
  /**
2596
2904
  * Search for agents by name or capability.
@@ -2650,10 +2958,14 @@ var VoidlyAgent = class _VoidlyAgent {
2650
2958
  * Delete a webhook.
2651
2959
  */
2652
2960
  async deleteWebhook(webhookId) {
2653
- await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2961
+ const res = await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2654
2962
  method: "DELETE",
2655
2963
  headers: { "X-Agent-Key": this.apiKey }
2656
2964
  });
2965
+ if (!res.ok) {
2966
+ const err = await res.json().catch(() => ({}));
2967
+ throw new Error(`Webhook delete failed: ${err.error || res.statusText}`);
2968
+ }
2657
2969
  }
2658
2970
  /**
2659
2971
  * Verify a webhook payload signature (for use in your webhook handler).
@@ -3271,13 +3583,21 @@ var VoidlyAgent = class _VoidlyAgent {
3271
3583
  * The relay finds matching agents and creates individual tasks for each.
3272
3584
  */
3273
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);
3274
3593
  const res = await fetch(`${this.baseUrl}/v1/agent/tasks/broadcast`, {
3275
3594
  method: "POST",
3276
3595
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3277
3596
  body: JSON.stringify({
3278
3597
  capability: options.capability,
3279
- encrypted_input: (0, import_tweetnacl_util.encodeBase64)((0, import_tweetnacl_util.decodeUTF8)(options.input)),
3280
- 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),
3281
3601
  priority: options.priority,
3282
3602
  max_agents: options.maxAgents,
3283
3603
  min_trust_level: options.minTrustLevel,
@@ -3347,16 +3667,30 @@ var VoidlyAgent = class _VoidlyAgent {
3347
3667
  // ─── Memory Store ──────────────────────────────────────────────────────────
3348
3668
  /**
3349
3669
  * Store an encrypted key-value pair in persistent memory.
3350
- * 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.
3351
3672
  */
3352
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);
3353
3686
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
3354
3687
  method: "PUT",
3355
3688
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3356
3689
  body: JSON.stringify({
3357
- value,
3358
- value_type: options?.valueType || (typeof value === "object" ? "json" : typeof value),
3359
- 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)
3360
3694
  })
3361
3695
  });
3362
3696
  if (!res.ok) throw new Error(`Memory set failed: ${res.status} ${await res.text()}`);
@@ -3364,7 +3698,7 @@ var VoidlyAgent = class _VoidlyAgent {
3364
3698
  }
3365
3699
  /**
3366
3700
  * Retrieve a value from persistent memory.
3367
- * Decrypted server-side using your API key derivation.
3701
+ * Decrypted CLIENT-SIDE relay never sees plaintext.
3368
3702
  */
3369
3703
  async memoryGet(namespace, key) {
3370
3704
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
@@ -3372,7 +3706,28 @@ var VoidlyAgent = class _VoidlyAgent {
3372
3706
  });
3373
3707
  if (res.status === 404) return null;
3374
3708
  if (!res.ok) throw new Error(`Memory get failed: ${res.status} ${await res.text()}`);
3375
- 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;
3376
3731
  }
3377
3732
  /**
3378
3733
  * Delete a key from persistent memory.
@@ -3788,6 +4143,96 @@ var VoidlyAgent = class _VoidlyAgent {
3788
4143
  }
3789
4144
  throw lastError || new Error("Send failed after retries");
3790
4145
  }
4146
+ // ═══════════════════════════════════════════════════════════════════════════
4147
+ // OFFLINE QUEUE — Resilience against relay downtime
4148
+ // ═══════════════════════════════════════════════════════════════════════════
4149
+ /**
4150
+ * Drain the offline message queue — retry sending queued messages.
4151
+ * Call this when connectivity is restored.
4152
+ * Returns: number of messages successfully sent.
4153
+ */
4154
+ async drainQueue() {
4155
+ let sent = 0;
4156
+ let failed = 0;
4157
+ const remaining = [];
4158
+ for (const item of this._offlineQueue) {
4159
+ if (Date.now() - item.timestamp > 864e5) {
4160
+ failed++;
4161
+ continue;
4162
+ }
4163
+ try {
4164
+ await this.send(item.recipientDid, item.message, item.options);
4165
+ sent++;
4166
+ } catch {
4167
+ remaining.push(item);
4168
+ failed++;
4169
+ }
4170
+ }
4171
+ this._offlineQueue = remaining;
4172
+ return { sent, failed, remaining: remaining.length };
4173
+ }
4174
+ /** Number of messages waiting in the offline queue */
4175
+ get queueLength() {
4176
+ return this._offlineQueue.length;
4177
+ }
4178
+ // ═══════════════════════════════════════════════════════════════════════════
4179
+ // SECURITY REPORT — Transparent threat model
4180
+ // ═══════════════════════════════════════════════════════════════════════════
4181
+ /**
4182
+ * Returns what the relay can and cannot see about this agent.
4183
+ * Call this to understand your threat model. Total transparency.
4184
+ */
4185
+ threatModel() {
4186
+ return {
4187
+ relayCanSee: [
4188
+ "Your DID (public identifier)",
4189
+ "Who you message (recipient DIDs)",
4190
+ "When you message (timestamps)",
4191
+ "Message types (text, task-request, etc.)",
4192
+ "Thread structure (which messages are replies)",
4193
+ "Channel membership",
4194
+ "Capability registrations",
4195
+ "Online/offline status",
4196
+ "Approximate message size (even with padding, bounded to power-of-2)"
4197
+ ],
4198
+ relayCannotSee: [
4199
+ "Message content (E2E encrypted \u2014 nacl.secretbox with ratchet-derived per-message keys)",
4200
+ "Private keys (generated and stored client-side only)",
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)",
4203
+ ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
4204
+ ],
4205
+ protections: [
4206
+ "Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted",
4207
+ "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
4208
+ "Ed25519 signatures on every message (envelope + ciphertext hash)",
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)",
4215
+ ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
4216
+ ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
4217
+ ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
4218
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
4219
+ "Auto-retry with exponential backoff",
4220
+ "Offline message queue",
4221
+ "did:key interoperability (W3C standard DID format)"
4222
+ ],
4223
+ gaps: [
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)",
4226
+ "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
4227
+ "Metadata (who, when, thread structure) visible to relay operator",
4228
+ "Single relay architecture (no onion routing, no mix network)",
4229
+ "Ed25519 signatures are non-repudiable (no deniable messaging)",
4230
+ "No async key agreement (no X3DH prekeys)",
4231
+ "Polling-based (no WebSocket real-time transport)",
4232
+ "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)"
4233
+ ]
4234
+ };
4235
+ }
3791
4236
  };
3792
4237
  var Conversation = class {
3793
4238
  /** @internal */
@@ -3810,6 +4255,9 @@ var Conversation = class {
3810
4255
  replyTo: this._lastMessageId || void 0
3811
4256
  });
3812
4257
  this._lastMessageId = result.id;
4258
+ if (this._messageHistory.length >= 1e3) {
4259
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4260
+ }
3813
4261
  this._messageHistory.push({
3814
4262
  id: result.id,
3815
4263
  from: this.agent.did,
@@ -3884,14 +4332,23 @@ var Conversation = class {
3884
4332
  async waitForReply(timeoutMs = 3e4) {
3885
4333
  return new Promise((resolve, reject) => {
3886
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
+ };
3887
4343
  const timeout = setTimeout(() => {
3888
4344
  if (!resolved) {
3889
- resolved = true;
4345
+ cleanup();
3890
4346
  reject(new Error(`No reply received within ${timeoutMs}ms`));
3891
4347
  }
3892
4348
  }, timeoutMs);
3893
4349
  const check = async () => {
3894
- while (!resolved) {
4350
+ if (resolved) return;
4351
+ try {
3895
4352
  const messages = await this.agent.receive({
3896
4353
  from: this.peerDid,
3897
4354
  threadId: this.threadId,
@@ -3899,9 +4356,12 @@ var Conversation = class {
3899
4356
  limit: 1
3900
4357
  });
3901
4358
  if (messages.length > 0 && !resolved) {
3902
- resolved = true;
3903
4359
  clearTimeout(timeout);
4360
+ cleanup();
3904
4361
  const msg = messages[0];
4362
+ if (this._messageHistory.length >= 1e3) {
4363
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4364
+ }
3905
4365
  this._messageHistory.push({
3906
4366
  id: msg.id,
3907
4367
  from: msg.from,
@@ -3916,16 +4376,19 @@ var Conversation = class {
3916
4376
  resolve(msg);
3917
4377
  return;
3918
4378
  }
3919
- 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
+ }
3920
4386
  }
3921
- };
3922
- check().catch((err) => {
3923
4387
  if (!resolved) {
3924
- resolved = true;
3925
- clearTimeout(timeout);
3926
- reject(err);
4388
+ pollTimer = setTimeout(check, 1500);
3927
4389
  }
3928
- });
4390
+ };
4391
+ check();
3929
4392
  });
3930
4393
  }
3931
4394
  /**