@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.js CHANGED
@@ -2346,11 +2346,107 @@ async function sha256(data) {
2346
2346
  const { createHash } = await import("crypto");
2347
2347
  return createHash("sha256").update(data).digest("hex");
2348
2348
  }
2349
+ function padMessage(content) {
2350
+ const contentLen = content.length;
2351
+ const totalLen = Math.max(256, nextPowerOf2(contentLen + 2));
2352
+ const padded = new Uint8Array(totalLen);
2353
+ padded[0] = contentLen >> 8 & 255;
2354
+ padded[1] = contentLen & 255;
2355
+ padded.set(content, 2);
2356
+ const randomPad = import_tweetnacl.default.randomBytes(totalLen - contentLen - 2);
2357
+ padded.set(randomPad, contentLen + 2);
2358
+ return padded;
2359
+ }
2360
+ function unpadMessage(padded) {
2361
+ if (padded.length < 2) return padded;
2362
+ const contentLen = padded[0] << 8 | padded[1];
2363
+ if (contentLen + 2 > padded.length) return padded;
2364
+ return padded.slice(2, 2 + contentLen);
2365
+ }
2366
+ function nextPowerOf2(n) {
2367
+ let p = 1;
2368
+ while (p < n) p <<= 1;
2369
+ return p;
2370
+ }
2371
+ async function ratchetStep(chainKey) {
2372
+ const encoder = new TextEncoder();
2373
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
2374
+ const ckInput = new Uint8Array([...chainKey, 1]);
2375
+ const mkInput = new Uint8Array([...chainKey, 2]);
2376
+ const nextChainKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", ckInput));
2377
+ const messageKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", mkInput));
2378
+ return { nextChainKey: nextChainKey2, messageKey: messageKey2 };
2379
+ }
2380
+ const { createHash } = await import("crypto");
2381
+ const nextChainKey = new Uint8Array(
2382
+ createHash("sha256").update(Buffer.from([...chainKey, 1])).digest()
2383
+ );
2384
+ const messageKey = new Uint8Array(
2385
+ createHash("sha256").update(Buffer.from([...chainKey, 2])).digest()
2386
+ );
2387
+ return { nextChainKey, messageKey };
2388
+ }
2389
+ function sealEnvelope(senderDid, plaintext) {
2390
+ return JSON.stringify({
2391
+ v: 2,
2392
+ from: senderDid,
2393
+ msg: plaintext,
2394
+ ts: (/* @__PURE__ */ new Date()).toISOString()
2395
+ });
2396
+ }
2397
+ function unsealEnvelope(plaintext) {
2398
+ try {
2399
+ const parsed = JSON.parse(plaintext);
2400
+ if (parsed.v === 2 && parsed.from && parsed.msg) {
2401
+ return { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
2402
+ }
2403
+ return null;
2404
+ } catch {
2405
+ return null;
2406
+ }
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
+ }
2349
2440
  var VoidlyAgent = class _VoidlyAgent {
2350
2441
  constructor(identity, config) {
2351
2442
  this._pinnedDids = /* @__PURE__ */ new Set();
2352
2443
  this._listeners = /* @__PURE__ */ new Set();
2353
2444
  this._conversations = /* @__PURE__ */ new Map();
2445
+ this._offlineQueue = [];
2446
+ this._ratchetStates = /* @__PURE__ */ new Map();
2447
+ this._identityCache = /* @__PURE__ */ new Map();
2448
+ this._seenMessageIds = /* @__PURE__ */ new Set();
2449
+ this._decryptFailCount = 0;
2354
2450
  this.did = identity.did;
2355
2451
  this.apiKey = identity.apiKey;
2356
2452
  this.signingKeyPair = identity.signingKeyPair;
@@ -2358,6 +2454,11 @@ var VoidlyAgent = class _VoidlyAgent {
2358
2454
  this.baseUrl = config?.baseUrl || "https://api.voidly.ai";
2359
2455
  this.autoPin = config?.autoPin !== false;
2360
2456
  this.defaultRetries = config?.retries ?? 3;
2457
+ this.fallbackRelays = config?.fallbackRelays || [];
2458
+ this.paddingEnabled = config?.padding !== false;
2459
+ this.sealedSender = config?.sealedSender || false;
2460
+ this.requireSignatures = config?.requireSignatures || false;
2461
+ this.timeout = config?.timeout ?? 3e4;
2361
2462
  }
2362
2463
  // ─── Factory Methods ────────────────────────────────────────────────────────
2363
2464
  /**
@@ -2396,8 +2497,29 @@ var VoidlyAgent = class _VoidlyAgent {
2396
2497
  * Use this to resume an agent across sessions.
2397
2498
  */
2398
2499
  static fromCredentials(creds, config) {
2399
- const signingSecret = (0, import_tweetnacl_util.decodeBase64)(creds.signingSecretKey);
2400
- 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
+ }
2401
2523
  return new _VoidlyAgent({
2402
2524
  did: creds.did,
2403
2525
  apiKey: creds.apiKey,
@@ -2422,18 +2544,43 @@ var VoidlyAgent = class _VoidlyAgent {
2422
2544
  encryptionPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.encryptionKeyPair.publicKey)
2423
2545
  };
2424
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
+ }
2425
2566
  // ─── Messaging ──────────────────────────────────────────────────────────────
2426
2567
  /**
2427
- * Send an E2E encrypted message with automatic retry and transparent TOFU.
2568
+ * Send an E2E encrypted message with hardened security.
2428
2569
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
2429
2570
  *
2430
- * Features:
2571
+ * Security features:
2572
+ * - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
2573
+ * - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
2574
+ * - **Sealed sender** — optionally hide sender DID from relay metadata
2431
2575
  * - **Auto-retry** with exponential backoff on transient failures
2432
- * - **Transparent TOFU** — automatically pins recipient keys on first contact
2433
- * - **Key verification** — warns if pinned keys have changed (potential MitM)
2576
+ * - **Multi-relay fallback** — try backup relays if primary is down
2577
+ * - **Offline queue** — queue messages if all relays fail
2578
+ * - **Transparent TOFU** — auto-pin recipient keys on first contact
2434
2579
  */
2435
2580
  async send(recipientDid, message, options = {}) {
2436
2581
  const maxRetries = options.retries ?? this.defaultRetries;
2582
+ const usePadding = !options.noPadding && this.paddingEnabled;
2583
+ const useSealed = options.sealedSender ?? this.sealedSender;
2437
2584
  const profile = await this.getIdentity(recipientDid);
2438
2585
  if (!profile) {
2439
2586
  throw new Error(`Recipient ${recipientDid} not found`);
@@ -2442,9 +2589,43 @@ var VoidlyAgent = class _VoidlyAgent {
2442
2589
  await this._autoPinKeys(recipientDid);
2443
2590
  }
2444
2591
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
2445
- const messageBytes = (0, import_tweetnacl_util.decodeUTF8)(message);
2446
- const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
2447
- const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
2592
+ let plaintext = message;
2593
+ if (useSealed) {
2594
+ plaintext = sealEnvelope(this.did, message);
2595
+ }
2596
+ let contentBytes;
2597
+ if (usePadding) {
2598
+ contentBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2599
+ } else {
2600
+ contentBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2601
+ }
2602
+ const pairId = `${this.did}:${recipientDid}`;
2603
+ let state = this._ratchetStates.get(pairId);
2604
+ if (!state) {
2605
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
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);
2615
+ }
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);
2448
2629
  if (!ciphertext) {
2449
2630
  throw new Error("Encryption failed");
2450
2631
  }
@@ -2453,7 +2634,8 @@ var VoidlyAgent = class _VoidlyAgent {
2453
2634
  to: recipientDid,
2454
2635
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2455
2636
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
2456
- 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
2457
2639
  });
2458
2640
  const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
2459
2641
  const payload = {
@@ -2468,24 +2650,42 @@ var VoidlyAgent = class _VoidlyAgent {
2468
2650
  reply_to: options.replyTo,
2469
2651
  ttl: options.ttl
2470
2652
  };
2471
- const raw = await this._fetchWithRetry(
2472
- `${this.baseUrl}/v1/agent/send/encrypted`,
2473
- {
2474
- method: "POST",
2475
- headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2476
- body: JSON.stringify(payload)
2477
- },
2478
- { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2479
- );
2480
- return {
2481
- id: raw.id,
2482
- from: raw.from,
2483
- to: raw.to,
2484
- timestamp: raw.timestamp,
2485
- expiresAt: raw.expires_at || raw.expiresAt,
2486
- encrypted: raw.encrypted,
2487
- clientSide: raw.client_side || raw.clientSide
2488
- };
2653
+ const relays = [this.baseUrl, ...this.fallbackRelays];
2654
+ let lastError = null;
2655
+ for (const relay of relays) {
2656
+ try {
2657
+ const raw = await this._fetchWithRetry(
2658
+ `${relay}/v1/agent/send/encrypted`,
2659
+ {
2660
+ method: "POST",
2661
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2662
+ body: JSON.stringify(payload)
2663
+ },
2664
+ { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2665
+ );
2666
+ return {
2667
+ id: raw.id,
2668
+ from: raw.from,
2669
+ to: raw.to,
2670
+ timestamp: raw.timestamp,
2671
+ expiresAt: raw.expires_at || raw.expiresAt,
2672
+ encrypted: raw.encrypted,
2673
+ clientSide: raw.client_side || raw.clientSide
2674
+ };
2675
+ } catch (err) {
2676
+ lastError = err instanceof Error ? err : new Error(String(err));
2677
+ if (lastError.message.includes("(4")) break;
2678
+ }
2679
+ }
2680
+ if (lastError && !lastError.message.includes("(4")) {
2681
+ this._offlineQueue.push({
2682
+ recipientDid,
2683
+ message,
2684
+ options,
2685
+ timestamp: Date.now()
2686
+ });
2687
+ }
2688
+ throw lastError || new Error("Send failed");
2489
2689
  }
2490
2690
  /**
2491
2691
  * Receive and decrypt messages. Decryption happens locally.
@@ -2511,17 +2711,105 @@ var VoidlyAgent = class _VoidlyAgent {
2511
2711
  const decrypted = [];
2512
2712
  for (const msg of data.messages) {
2513
2713
  try {
2714
+ if (this._seenMessageIds.has(msg.id)) continue;
2514
2715
  const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
2515
2716
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
2516
2717
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
2517
- const plaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2518
- if (!plaintext) 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
+ }
2781
+ let plaintextBytes = rawPlaintext;
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) {
2793
+ const unpadded = unpadMessage(rawPlaintext);
2794
+ if (unpadded.length < rawPlaintext.length) {
2795
+ plaintextBytes = unpadded;
2796
+ }
2797
+ }
2798
+ let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
2799
+ let senderDid = msg.from;
2800
+ if (wasSealed || !proto) {
2801
+ const unsealed = unsealEnvelope(content);
2802
+ if (unsealed) {
2803
+ content = unsealed.msg;
2804
+ senderDid = unsealed.from;
2805
+ }
2806
+ }
2519
2807
  let signatureValid = false;
2520
2808
  try {
2521
2809
  const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
2522
2810
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
2523
2811
  const envelopeStr = msg.envelope || JSON.stringify({
2524
- from: msg.from,
2812
+ from: senderDid,
2525
2813
  to: msg.to,
2526
2814
  timestamp: msg.timestamp,
2527
2815
  nonce: msg.nonce,
@@ -2535,11 +2823,20 @@ var VoidlyAgent = class _VoidlyAgent {
2535
2823
  } catch {
2536
2824
  signatureValid = false;
2537
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
+ }
2538
2835
  decrypted.push({
2539
2836
  id: msg.id,
2540
- from: msg.from,
2837
+ from: senderDid,
2541
2838
  to: msg.to,
2542
- content: (0, import_tweetnacl_util.encodeUTF8)(plaintext),
2839
+ content,
2543
2840
  contentType: msg.content_type,
2544
2841
  messageType: msg.message_type || "text",
2545
2842
  threadId: msg.thread_id,
@@ -2549,6 +2846,7 @@ var VoidlyAgent = class _VoidlyAgent {
2549
2846
  expiresAt: msg.expires_at
2550
2847
  });
2551
2848
  } catch {
2849
+ this._decryptFailCount++;
2552
2850
  }
2553
2851
  }
2554
2852
  return decrypted;
@@ -2599,9 +2897,19 @@ var VoidlyAgent = class _VoidlyAgent {
2599
2897
  * Look up an agent's public profile and keys.
2600
2898
  */
2601
2899
  async getIdentity(did) {
2900
+ const cached = this._identityCache.get(did);
2901
+ if (cached && Date.now() - cached.cachedAt < 3e5) {
2902
+ return cached.profile;
2903
+ }
2602
2904
  const res = await fetch(`${this.baseUrl}/v1/agent/identity/${did}`);
2603
2905
  if (!res.ok) return null;
2604
- 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;
2605
2913
  }
2606
2914
  /**
2607
2915
  * Search for agents by name or capability.
@@ -2661,10 +2969,14 @@ var VoidlyAgent = class _VoidlyAgent {
2661
2969
  * Delete a webhook.
2662
2970
  */
2663
2971
  async deleteWebhook(webhookId) {
2664
- await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2972
+ const res = await fetch(`${this.baseUrl}/v1/agent/webhooks/${webhookId}`, {
2665
2973
  method: "DELETE",
2666
2974
  headers: { "X-Agent-Key": this.apiKey }
2667
2975
  });
2976
+ if (!res.ok) {
2977
+ const err = await res.json().catch(() => ({}));
2978
+ throw new Error(`Webhook delete failed: ${err.error || res.statusText}`);
2979
+ }
2668
2980
  }
2669
2981
  /**
2670
2982
  * Verify a webhook payload signature (for use in your webhook handler).
@@ -3282,13 +3594,21 @@ var VoidlyAgent = class _VoidlyAgent {
3282
3594
  * The relay finds matching agents and creates individual tasks for each.
3283
3595
  */
3284
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);
3285
3604
  const res = await fetch(`${this.baseUrl}/v1/agent/tasks/broadcast`, {
3286
3605
  method: "POST",
3287
3606
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3288
3607
  body: JSON.stringify({
3289
3608
  capability: options.capability,
3290
- encrypted_input: (0, import_tweetnacl_util.encodeBase64)((0, import_tweetnacl_util.decodeUTF8)(options.input)),
3291
- 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),
3292
3612
  priority: options.priority,
3293
3613
  max_agents: options.maxAgents,
3294
3614
  min_trust_level: options.minTrustLevel,
@@ -3358,16 +3678,30 @@ var VoidlyAgent = class _VoidlyAgent {
3358
3678
  // ─── Memory Store ──────────────────────────────────────────────────────────
3359
3679
  /**
3360
3680
  * Store an encrypted key-value pair in persistent memory.
3361
- * 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.
3362
3683
  */
3363
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);
3364
3697
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
3365
3698
  method: "PUT",
3366
3699
  headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
3367
3700
  body: JSON.stringify({
3368
- value,
3369
- value_type: options?.valueType || (typeof value === "object" ? "json" : typeof value),
3370
- 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)
3371
3705
  })
3372
3706
  });
3373
3707
  if (!res.ok) throw new Error(`Memory set failed: ${res.status} ${await res.text()}`);
@@ -3375,7 +3709,7 @@ var VoidlyAgent = class _VoidlyAgent {
3375
3709
  }
3376
3710
  /**
3377
3711
  * Retrieve a value from persistent memory.
3378
- * Decrypted server-side using your API key derivation.
3712
+ * Decrypted CLIENT-SIDE relay never sees plaintext.
3379
3713
  */
3380
3714
  async memoryGet(namespace, key) {
3381
3715
  const res = await fetch(`${this.baseUrl}/v1/agent/memory/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, {
@@ -3383,7 +3717,28 @@ var VoidlyAgent = class _VoidlyAgent {
3383
3717
  });
3384
3718
  if (res.status === 404) return null;
3385
3719
  if (!res.ok) throw new Error(`Memory get failed: ${res.status} ${await res.text()}`);
3386
- 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;
3387
3742
  }
3388
3743
  /**
3389
3744
  * Delete a key from persistent memory.
@@ -3799,6 +4154,96 @@ var VoidlyAgent = class _VoidlyAgent {
3799
4154
  }
3800
4155
  throw lastError || new Error("Send failed after retries");
3801
4156
  }
4157
+ // ═══════════════════════════════════════════════════════════════════════════
4158
+ // OFFLINE QUEUE — Resilience against relay downtime
4159
+ // ═══════════════════════════════════════════════════════════════════════════
4160
+ /**
4161
+ * Drain the offline message queue — retry sending queued messages.
4162
+ * Call this when connectivity is restored.
4163
+ * Returns: number of messages successfully sent.
4164
+ */
4165
+ async drainQueue() {
4166
+ let sent = 0;
4167
+ let failed = 0;
4168
+ const remaining = [];
4169
+ for (const item of this._offlineQueue) {
4170
+ if (Date.now() - item.timestamp > 864e5) {
4171
+ failed++;
4172
+ continue;
4173
+ }
4174
+ try {
4175
+ await this.send(item.recipientDid, item.message, item.options);
4176
+ sent++;
4177
+ } catch {
4178
+ remaining.push(item);
4179
+ failed++;
4180
+ }
4181
+ }
4182
+ this._offlineQueue = remaining;
4183
+ return { sent, failed, remaining: remaining.length };
4184
+ }
4185
+ /** Number of messages waiting in the offline queue */
4186
+ get queueLength() {
4187
+ return this._offlineQueue.length;
4188
+ }
4189
+ // ═══════════════════════════════════════════════════════════════════════════
4190
+ // SECURITY REPORT — Transparent threat model
4191
+ // ═══════════════════════════════════════════════════════════════════════════
4192
+ /**
4193
+ * Returns what the relay can and cannot see about this agent.
4194
+ * Call this to understand your threat model. Total transparency.
4195
+ */
4196
+ threatModel() {
4197
+ return {
4198
+ relayCanSee: [
4199
+ "Your DID (public identifier)",
4200
+ "Who you message (recipient DIDs)",
4201
+ "When you message (timestamps)",
4202
+ "Message types (text, task-request, etc.)",
4203
+ "Thread structure (which messages are replies)",
4204
+ "Channel membership",
4205
+ "Capability registrations",
4206
+ "Online/offline status",
4207
+ "Approximate message size (even with padding, bounded to power-of-2)"
4208
+ ],
4209
+ relayCannotSee: [
4210
+ "Message content (E2E encrypted \u2014 nacl.secretbox with ratchet-derived per-message keys)",
4211
+ "Private keys (generated and stored client-side only)",
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)",
4214
+ ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
4215
+ ],
4216
+ protections: [
4217
+ "Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted",
4218
+ "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
4219
+ "Ed25519 signatures on every message (envelope + ciphertext hash)",
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)",
4226
+ ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
4227
+ ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
4228
+ ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
4229
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
4230
+ "Auto-retry with exponential backoff",
4231
+ "Offline message queue",
4232
+ "did:key interoperability (W3C standard DID format)"
4233
+ ],
4234
+ gaps: [
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)",
4237
+ "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
4238
+ "Metadata (who, when, thread structure) visible to relay operator",
4239
+ "Single relay architecture (no onion routing, no mix network)",
4240
+ "Ed25519 signatures are non-repudiable (no deniable messaging)",
4241
+ "No async key agreement (no X3DH prekeys)",
4242
+ "Polling-based (no WebSocket real-time transport)",
4243
+ "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)"
4244
+ ]
4245
+ };
4246
+ }
3802
4247
  };
3803
4248
  var Conversation = class {
3804
4249
  /** @internal */
@@ -3821,6 +4266,9 @@ var Conversation = class {
3821
4266
  replyTo: this._lastMessageId || void 0
3822
4267
  });
3823
4268
  this._lastMessageId = result.id;
4269
+ if (this._messageHistory.length >= 1e3) {
4270
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4271
+ }
3824
4272
  this._messageHistory.push({
3825
4273
  id: result.id,
3826
4274
  from: this.agent.did,
@@ -3895,14 +4343,23 @@ var Conversation = class {
3895
4343
  async waitForReply(timeoutMs = 3e4) {
3896
4344
  return new Promise((resolve, reject) => {
3897
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
+ };
3898
4354
  const timeout = setTimeout(() => {
3899
4355
  if (!resolved) {
3900
- resolved = true;
4356
+ cleanup();
3901
4357
  reject(new Error(`No reply received within ${timeoutMs}ms`));
3902
4358
  }
3903
4359
  }, timeoutMs);
3904
4360
  const check = async () => {
3905
- while (!resolved) {
4361
+ if (resolved) return;
4362
+ try {
3906
4363
  const messages = await this.agent.receive({
3907
4364
  from: this.peerDid,
3908
4365
  threadId: this.threadId,
@@ -3910,9 +4367,12 @@ var Conversation = class {
3910
4367
  limit: 1
3911
4368
  });
3912
4369
  if (messages.length > 0 && !resolved) {
3913
- resolved = true;
3914
4370
  clearTimeout(timeout);
4371
+ cleanup();
3915
4372
  const msg = messages[0];
4373
+ if (this._messageHistory.length >= 1e3) {
4374
+ this._messageHistory.splice(0, this._messageHistory.length - 999);
4375
+ }
3916
4376
  this._messageHistory.push({
3917
4377
  id: msg.id,
3918
4378
  from: msg.from,
@@ -3927,16 +4387,19 @@ var Conversation = class {
3927
4387
  resolve(msg);
3928
4388
  return;
3929
4389
  }
3930
- 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
+ }
3931
4397
  }
3932
- };
3933
- check().catch((err) => {
3934
4398
  if (!resolved) {
3935
- resolved = true;
3936
- clearTimeout(timeout);
3937
- reject(err);
4399
+ pollTimer = setTimeout(check, 1500);
3938
4400
  }
3939
- });
4401
+ };
4402
+ check();
3940
4403
  });
3941
4404
  }
3942
4405
  /**