@voidly/agent-sdk 1.9.0 → 2.0.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
@@ -54,6 +54,12 @@ interface VoidlyAgentConfig {
54
54
  autoPin?: boolean;
55
55
  /** Default retry attempts for send() (default: 3) */
56
56
  retries?: number;
57
+ /** Fallback relays — if primary fails, try these in order */
58
+ fallbackRelays?: string[];
59
+ /** Enable message padding to resist traffic analysis (default: true) */
60
+ padding?: boolean;
61
+ /** Enable sealed sender — hide sender DID from relay metadata (default: false) */
62
+ sealedSender?: boolean;
57
63
  }
58
64
  interface ListenOptions {
59
65
  /** Milliseconds between polls (default: 2000, min: 500) */
@@ -128,9 +134,14 @@ declare class VoidlyAgent {
128
134
  private baseUrl;
129
135
  private autoPin;
130
136
  private defaultRetries;
137
+ private fallbackRelays;
138
+ private paddingEnabled;
139
+ private sealedSender;
131
140
  private _pinnedDids;
132
141
  private _listeners;
133
142
  private _conversations;
143
+ private _offlineQueue;
144
+ private _ratchetStates;
134
145
  private constructor();
135
146
  /**
136
147
  * Register a new agent on the Voidly relay.
@@ -163,13 +174,17 @@ declare class VoidlyAgent {
163
174
  encryptionPublicKey: string;
164
175
  };
165
176
  /**
166
- * Send an E2E encrypted message with automatic retry and transparent TOFU.
177
+ * Send an E2E encrypted message with hardened security.
167
178
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
168
179
  *
169
- * Features:
180
+ * Security features:
181
+ * - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
182
+ * - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
183
+ * - **Sealed sender** — optionally hide sender DID from relay metadata
170
184
  * - **Auto-retry** with exponential backoff on transient failures
171
- * - **Transparent TOFU** — automatically pins recipient keys on first contact
172
- * - **Key verification** — warns if pinned keys have changed (potential MitM)
185
+ * - **Multi-relay fallback** — try backup relays if primary is down
186
+ * - **Offline queue** — queue messages if all relays fail
187
+ * - **Transparent TOFU** — auto-pin recipient keys on first contact
173
188
  */
174
189
  send(recipientDid: string, message: string, options?: {
175
190
  contentType?: string;
@@ -181,6 +196,10 @@ declare class VoidlyAgent {
181
196
  retries?: number;
182
197
  /** Skip auto key pinning for this message */
183
198
  skipPin?: boolean;
199
+ /** Force sealed sender for this message */
200
+ sealedSender?: boolean;
201
+ /** Disable padding for this message */
202
+ noPadding?: boolean;
184
203
  }): Promise<SendResult>;
185
204
  /**
186
205
  * Receive and decrypt messages. Decryption happens locally.
@@ -842,6 +861,28 @@ declare class VoidlyAgent {
842
861
  private _autoPinKeys;
843
862
  /** @internal Fetch with exponential backoff retry */
844
863
  private _fetchWithRetry;
864
+ /**
865
+ * Drain the offline message queue — retry sending queued messages.
866
+ * Call this when connectivity is restored.
867
+ * Returns: number of messages successfully sent.
868
+ */
869
+ drainQueue(): Promise<{
870
+ sent: number;
871
+ failed: number;
872
+ remaining: number;
873
+ }>;
874
+ /** Number of messages waiting in the offline queue */
875
+ get queueLength(): number;
876
+ /**
877
+ * Returns what the relay can and cannot see about this agent.
878
+ * Call this to understand your threat model. Total transparency.
879
+ */
880
+ threatModel(): {
881
+ relayCanSee: string[];
882
+ relayCannotSee: string[];
883
+ protections: string[];
884
+ gaps: string[];
885
+ };
845
886
  }
846
887
  /**
847
888
  * A conversation between two agents.
package/dist/index.d.ts CHANGED
@@ -54,6 +54,12 @@ interface VoidlyAgentConfig {
54
54
  autoPin?: boolean;
55
55
  /** Default retry attempts for send() (default: 3) */
56
56
  retries?: number;
57
+ /** Fallback relays — if primary fails, try these in order */
58
+ fallbackRelays?: string[];
59
+ /** Enable message padding to resist traffic analysis (default: true) */
60
+ padding?: boolean;
61
+ /** Enable sealed sender — hide sender DID from relay metadata (default: false) */
62
+ sealedSender?: boolean;
57
63
  }
58
64
  interface ListenOptions {
59
65
  /** Milliseconds between polls (default: 2000, min: 500) */
@@ -128,9 +134,14 @@ declare class VoidlyAgent {
128
134
  private baseUrl;
129
135
  private autoPin;
130
136
  private defaultRetries;
137
+ private fallbackRelays;
138
+ private paddingEnabled;
139
+ private sealedSender;
131
140
  private _pinnedDids;
132
141
  private _listeners;
133
142
  private _conversations;
143
+ private _offlineQueue;
144
+ private _ratchetStates;
134
145
  private constructor();
135
146
  /**
136
147
  * Register a new agent on the Voidly relay.
@@ -163,13 +174,17 @@ declare class VoidlyAgent {
163
174
  encryptionPublicKey: string;
164
175
  };
165
176
  /**
166
- * Send an E2E encrypted message with automatic retry and transparent TOFU.
177
+ * Send an E2E encrypted message with hardened security.
167
178
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
168
179
  *
169
- * Features:
180
+ * Security features:
181
+ * - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
182
+ * - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
183
+ * - **Sealed sender** — optionally hide sender DID from relay metadata
170
184
  * - **Auto-retry** with exponential backoff on transient failures
171
- * - **Transparent TOFU** — automatically pins recipient keys on first contact
172
- * - **Key verification** — warns if pinned keys have changed (potential MitM)
185
+ * - **Multi-relay fallback** — try backup relays if primary is down
186
+ * - **Offline queue** — queue messages if all relays fail
187
+ * - **Transparent TOFU** — auto-pin recipient keys on first contact
173
188
  */
174
189
  send(recipientDid: string, message: string, options?: {
175
190
  contentType?: string;
@@ -181,6 +196,10 @@ declare class VoidlyAgent {
181
196
  retries?: number;
182
197
  /** Skip auto key pinning for this message */
183
198
  skipPin?: boolean;
199
+ /** Force sealed sender for this message */
200
+ sealedSender?: boolean;
201
+ /** Disable padding for this message */
202
+ noPadding?: boolean;
184
203
  }): Promise<SendResult>;
185
204
  /**
186
205
  * Receive and decrypt messages. Decryption happens locally.
@@ -842,6 +861,28 @@ declare class VoidlyAgent {
842
861
  private _autoPinKeys;
843
862
  /** @internal Fetch with exponential backoff retry */
844
863
  private _fetchWithRetry;
864
+ /**
865
+ * Drain the offline message queue — retry sending queued messages.
866
+ * Call this when connectivity is restored.
867
+ * Returns: number of messages successfully sent.
868
+ */
869
+ drainQueue(): Promise<{
870
+ sent: number;
871
+ failed: number;
872
+ remaining: number;
873
+ }>;
874
+ /** Number of messages waiting in the offline queue */
875
+ get queueLength(): number;
876
+ /**
877
+ * Returns what the relay can and cannot see about this agent.
878
+ * Call this to understand your threat model. Total transparency.
879
+ */
880
+ threatModel(): {
881
+ relayCanSee: string[];
882
+ relayCannotSee: string[];
883
+ protections: string[];
884
+ gaps: string[];
885
+ };
845
886
  }
846
887
  /**
847
888
  * A conversation between two agents.
package/dist/index.js CHANGED
@@ -2346,11 +2346,72 @@ 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
+ }
2349
2408
  var VoidlyAgent = class _VoidlyAgent {
2350
2409
  constructor(identity, config) {
2351
2410
  this._pinnedDids = /* @__PURE__ */ new Set();
2352
2411
  this._listeners = /* @__PURE__ */ new Set();
2353
2412
  this._conversations = /* @__PURE__ */ new Map();
2413
+ this._offlineQueue = [];
2414
+ this._ratchetStates = /* @__PURE__ */ new Map();
2354
2415
  this.did = identity.did;
2355
2416
  this.apiKey = identity.apiKey;
2356
2417
  this.signingKeyPair = identity.signingKeyPair;
@@ -2358,6 +2419,9 @@ var VoidlyAgent = class _VoidlyAgent {
2358
2419
  this.baseUrl = config?.baseUrl || "https://api.voidly.ai";
2359
2420
  this.autoPin = config?.autoPin !== false;
2360
2421
  this.defaultRetries = config?.retries ?? 3;
2422
+ this.fallbackRelays = config?.fallbackRelays || [];
2423
+ this.paddingEnabled = config?.padding !== false;
2424
+ this.sealedSender = config?.sealedSender || false;
2361
2425
  }
2362
2426
  // ─── Factory Methods ────────────────────────────────────────────────────────
2363
2427
  /**
@@ -2424,16 +2488,22 @@ var VoidlyAgent = class _VoidlyAgent {
2424
2488
  }
2425
2489
  // ─── Messaging ──────────────────────────────────────────────────────────────
2426
2490
  /**
2427
- * Send an E2E encrypted message with automatic retry and transparent TOFU.
2491
+ * Send an E2E encrypted message with hardened security.
2428
2492
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
2429
2493
  *
2430
- * Features:
2494
+ * Security features:
2495
+ * - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
2496
+ * - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
2497
+ * - **Sealed sender** — optionally hide sender DID from relay metadata
2431
2498
  * - **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)
2499
+ * - **Multi-relay fallback** — try backup relays if primary is down
2500
+ * - **Offline queue** — queue messages if all relays fail
2501
+ * - **Transparent TOFU** — auto-pin recipient keys on first contact
2434
2502
  */
2435
2503
  async send(recipientDid, message, options = {}) {
2436
2504
  const maxRetries = options.retries ?? this.defaultRetries;
2505
+ const usePadding = !options.noPadding && this.paddingEnabled;
2506
+ const useSealed = options.sealedSender ?? this.sealedSender;
2437
2507
  const profile = await this.getIdentity(recipientDid);
2438
2508
  if (!profile) {
2439
2509
  throw new Error(`Recipient ${recipientDid} not found`);
@@ -2442,7 +2512,31 @@ var VoidlyAgent = class _VoidlyAgent {
2442
2512
  await this._autoPinKeys(recipientDid);
2443
2513
  }
2444
2514
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
2445
- const messageBytes = (0, import_tweetnacl_util.decodeUTF8)(message);
2515
+ let plaintext = message;
2516
+ if (useSealed) {
2517
+ plaintext = sealEnvelope(this.did, message);
2518
+ }
2519
+ let messageBytes;
2520
+ if (usePadding) {
2521
+ messageBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2522
+ } else {
2523
+ messageBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2524
+ }
2525
+ 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 {
2534
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
2535
+ this._ratchetStates.set(pairId, {
2536
+ chainKey: sharedSecret,
2537
+ step: 0
2538
+ });
2539
+ }
2446
2540
  const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
2447
2541
  const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
2448
2542
  if (!ciphertext) {
@@ -2468,24 +2562,42 @@ var VoidlyAgent = class _VoidlyAgent {
2468
2562
  reply_to: options.replyTo,
2469
2563
  ttl: options.ttl
2470
2564
  };
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
- };
2565
+ const relays = [this.baseUrl, ...this.fallbackRelays];
2566
+ let lastError = null;
2567
+ for (const relay of relays) {
2568
+ try {
2569
+ const raw = await this._fetchWithRetry(
2570
+ `${relay}/v1/agent/send/encrypted`,
2571
+ {
2572
+ method: "POST",
2573
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2574
+ body: JSON.stringify(payload)
2575
+ },
2576
+ { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2577
+ );
2578
+ return {
2579
+ id: raw.id,
2580
+ from: raw.from,
2581
+ to: raw.to,
2582
+ timestamp: raw.timestamp,
2583
+ expiresAt: raw.expires_at || raw.expiresAt,
2584
+ encrypted: raw.encrypted,
2585
+ clientSide: raw.client_side || raw.clientSide
2586
+ };
2587
+ } catch (err) {
2588
+ lastError = err instanceof Error ? err : new Error(String(err));
2589
+ if (lastError.message.includes("(4")) break;
2590
+ }
2591
+ }
2592
+ if (lastError && !lastError.message.includes("(4")) {
2593
+ this._offlineQueue.push({
2594
+ recipientDid,
2595
+ message,
2596
+ options,
2597
+ timestamp: Date.now()
2598
+ });
2599
+ }
2600
+ throw lastError || new Error("Send failed");
2489
2601
  }
2490
2602
  /**
2491
2603
  * Receive and decrypt messages. Decryption happens locally.
@@ -2514,14 +2626,28 @@ var VoidlyAgent = class _VoidlyAgent {
2514
2626
  const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
2515
2627
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
2516
2628
  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;
2629
+ const rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2630
+ if (!rawPlaintext) continue;
2631
+ let plaintextBytes = rawPlaintext;
2632
+ if (rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
2633
+ const unpadded = unpadMessage(rawPlaintext);
2634
+ if (unpadded.length < rawPlaintext.length) {
2635
+ plaintextBytes = unpadded;
2636
+ }
2637
+ }
2638
+ let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
2639
+ let senderDid = msg.from;
2640
+ const unsealed = unsealEnvelope(content);
2641
+ if (unsealed) {
2642
+ content = unsealed.msg;
2643
+ senderDid = unsealed.from;
2644
+ }
2519
2645
  let signatureValid = false;
2520
2646
  try {
2521
2647
  const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
2522
2648
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
2523
2649
  const envelopeStr = msg.envelope || JSON.stringify({
2524
- from: msg.from,
2650
+ from: senderDid,
2525
2651
  to: msg.to,
2526
2652
  timestamp: msg.timestamp,
2527
2653
  nonce: msg.nonce,
@@ -2537,9 +2663,9 @@ var VoidlyAgent = class _VoidlyAgent {
2537
2663
  }
2538
2664
  decrypted.push({
2539
2665
  id: msg.id,
2540
- from: msg.from,
2666
+ from: senderDid,
2541
2667
  to: msg.to,
2542
- content: (0, import_tweetnacl_util.encodeUTF8)(plaintext),
2668
+ content,
2543
2669
  contentType: msg.content_type,
2544
2670
  messageType: msg.message_type || "text",
2545
2671
  threadId: msg.thread_id,
@@ -3799,6 +3925,87 @@ var VoidlyAgent = class _VoidlyAgent {
3799
3925
  }
3800
3926
  throw lastError || new Error("Send failed after retries");
3801
3927
  }
3928
+ // ═══════════════════════════════════════════════════════════════════════════
3929
+ // OFFLINE QUEUE — Resilience against relay downtime
3930
+ // ═══════════════════════════════════════════════════════════════════════════
3931
+ /**
3932
+ * Drain the offline message queue — retry sending queued messages.
3933
+ * Call this when connectivity is restored.
3934
+ * Returns: number of messages successfully sent.
3935
+ */
3936
+ async drainQueue() {
3937
+ let sent = 0;
3938
+ let failed = 0;
3939
+ const remaining = [];
3940
+ for (const item of this._offlineQueue) {
3941
+ if (Date.now() - item.timestamp > 864e5) {
3942
+ failed++;
3943
+ continue;
3944
+ }
3945
+ try {
3946
+ await this.send(item.recipientDid, item.message, item.options);
3947
+ sent++;
3948
+ } catch {
3949
+ remaining.push(item);
3950
+ failed++;
3951
+ }
3952
+ }
3953
+ this._offlineQueue = remaining;
3954
+ return { sent, failed, remaining: remaining.length };
3955
+ }
3956
+ /** Number of messages waiting in the offline queue */
3957
+ get queueLength() {
3958
+ return this._offlineQueue.length;
3959
+ }
3960
+ // ═══════════════════════════════════════════════════════════════════════════
3961
+ // SECURITY REPORT — Transparent threat model
3962
+ // ═══════════════════════════════════════════════════════════════════════════
3963
+ /**
3964
+ * Returns what the relay can and cannot see about this agent.
3965
+ * Call this to understand your threat model. Total transparency.
3966
+ */
3967
+ threatModel() {
3968
+ return {
3969
+ relayCanSee: [
3970
+ "Your DID (public identifier)",
3971
+ "Who you message (recipient DIDs)",
3972
+ "When you message (timestamps)",
3973
+ "Message types (text, task-request, etc.)",
3974
+ "Thread structure (which messages are replies)",
3975
+ "Channel membership",
3976
+ "Capability registrations",
3977
+ "Online/offline status",
3978
+ "Approximate message size (even with padding, bounded to power-of-2)"
3979
+ ],
3980
+ relayCannotSee: [
3981
+ "Message content (E2E encrypted with NaCl box)",
3982
+ "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)",
3985
+ ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
3986
+ ],
3987
+ protections: [
3988
+ "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
3989
+ "Ed25519 signatures on every message",
3990
+ "TOFU key pinning (MitM detection on key change)",
3991
+ ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
3992
+ ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
3993
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
3994
+ "Auto-retry with exponential backoff",
3995
+ "Offline message queue"
3996
+ ],
3997
+ 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",
4000
+ "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
4001
+ "Metadata (who, when, thread structure) visible to relay operator",
4002
+ "Single relay architecture (no onion routing, no mix network)",
4003
+ "Ed25519 signatures are non-repudiable (no deniable messaging)",
4004
+ "No async key agreement (no X3DH prekeys)",
4005
+ "Polling-based (no WebSocket real-time transport)"
4006
+ ]
4007
+ };
4008
+ }
3802
4009
  };
3803
4010
  var Conversation = class {
3804
4011
  /** @internal */
package/dist/index.mjs CHANGED
@@ -2335,11 +2335,72 @@ 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
+ }
2338
2397
  var VoidlyAgent = class _VoidlyAgent {
2339
2398
  constructor(identity, config) {
2340
2399
  this._pinnedDids = /* @__PURE__ */ new Set();
2341
2400
  this._listeners = /* @__PURE__ */ new Set();
2342
2401
  this._conversations = /* @__PURE__ */ new Map();
2402
+ this._offlineQueue = [];
2403
+ this._ratchetStates = /* @__PURE__ */ new Map();
2343
2404
  this.did = identity.did;
2344
2405
  this.apiKey = identity.apiKey;
2345
2406
  this.signingKeyPair = identity.signingKeyPair;
@@ -2347,6 +2408,9 @@ var VoidlyAgent = class _VoidlyAgent {
2347
2408
  this.baseUrl = config?.baseUrl || "https://api.voidly.ai";
2348
2409
  this.autoPin = config?.autoPin !== false;
2349
2410
  this.defaultRetries = config?.retries ?? 3;
2411
+ this.fallbackRelays = config?.fallbackRelays || [];
2412
+ this.paddingEnabled = config?.padding !== false;
2413
+ this.sealedSender = config?.sealedSender || false;
2350
2414
  }
2351
2415
  // ─── Factory Methods ────────────────────────────────────────────────────────
2352
2416
  /**
@@ -2413,16 +2477,22 @@ var VoidlyAgent = class _VoidlyAgent {
2413
2477
  }
2414
2478
  // ─── Messaging ──────────────────────────────────────────────────────────────
2415
2479
  /**
2416
- * Send an E2E encrypted message with automatic retry and transparent TOFU.
2480
+ * Send an E2E encrypted message with hardened security.
2417
2481
  * Encryption happens locally — the relay NEVER sees plaintext or private keys.
2418
2482
  *
2419
- * Features:
2483
+ * Security features:
2484
+ * - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
2485
+ * - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
2486
+ * - **Sealed sender** — optionally hide sender DID from relay metadata
2420
2487
  * - **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)
2488
+ * - **Multi-relay fallback** — try backup relays if primary is down
2489
+ * - **Offline queue** — queue messages if all relays fail
2490
+ * - **Transparent TOFU** — auto-pin recipient keys on first contact
2423
2491
  */
2424
2492
  async send(recipientDid, message, options = {}) {
2425
2493
  const maxRetries = options.retries ?? this.defaultRetries;
2494
+ const usePadding = !options.noPadding && this.paddingEnabled;
2495
+ const useSealed = options.sealedSender ?? this.sealedSender;
2426
2496
  const profile = await this.getIdentity(recipientDid);
2427
2497
  if (!profile) {
2428
2498
  throw new Error(`Recipient ${recipientDid} not found`);
@@ -2431,7 +2501,31 @@ var VoidlyAgent = class _VoidlyAgent {
2431
2501
  await this._autoPinKeys(recipientDid);
2432
2502
  }
2433
2503
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
2434
- const messageBytes = (0, import_tweetnacl_util.decodeUTF8)(message);
2504
+ let plaintext = message;
2505
+ if (useSealed) {
2506
+ plaintext = sealEnvelope(this.did, message);
2507
+ }
2508
+ let messageBytes;
2509
+ if (usePadding) {
2510
+ messageBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2511
+ } else {
2512
+ messageBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2513
+ }
2514
+ 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 {
2523
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
2524
+ this._ratchetStates.set(pairId, {
2525
+ chainKey: sharedSecret,
2526
+ step: 0
2527
+ });
2528
+ }
2435
2529
  const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
2436
2530
  const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
2437
2531
  if (!ciphertext) {
@@ -2457,24 +2551,42 @@ var VoidlyAgent = class _VoidlyAgent {
2457
2551
  reply_to: options.replyTo,
2458
2552
  ttl: options.ttl
2459
2553
  };
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
- };
2554
+ const relays = [this.baseUrl, ...this.fallbackRelays];
2555
+ let lastError = null;
2556
+ for (const relay of relays) {
2557
+ try {
2558
+ const raw = await this._fetchWithRetry(
2559
+ `${relay}/v1/agent/send/encrypted`,
2560
+ {
2561
+ method: "POST",
2562
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2563
+ body: JSON.stringify(payload)
2564
+ },
2565
+ { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2566
+ );
2567
+ return {
2568
+ id: raw.id,
2569
+ from: raw.from,
2570
+ to: raw.to,
2571
+ timestamp: raw.timestamp,
2572
+ expiresAt: raw.expires_at || raw.expiresAt,
2573
+ encrypted: raw.encrypted,
2574
+ clientSide: raw.client_side || raw.clientSide
2575
+ };
2576
+ } catch (err) {
2577
+ lastError = err instanceof Error ? err : new Error(String(err));
2578
+ if (lastError.message.includes("(4")) break;
2579
+ }
2580
+ }
2581
+ if (lastError && !lastError.message.includes("(4")) {
2582
+ this._offlineQueue.push({
2583
+ recipientDid,
2584
+ message,
2585
+ options,
2586
+ timestamp: Date.now()
2587
+ });
2588
+ }
2589
+ throw lastError || new Error("Send failed");
2478
2590
  }
2479
2591
  /**
2480
2592
  * Receive and decrypt messages. Decryption happens locally.
@@ -2503,14 +2615,28 @@ var VoidlyAgent = class _VoidlyAgent {
2503
2615
  const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
2504
2616
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
2505
2617
  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;
2618
+ const rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2619
+ if (!rawPlaintext) continue;
2620
+ let plaintextBytes = rawPlaintext;
2621
+ if (rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
2622
+ const unpadded = unpadMessage(rawPlaintext);
2623
+ if (unpadded.length < rawPlaintext.length) {
2624
+ plaintextBytes = unpadded;
2625
+ }
2626
+ }
2627
+ let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
2628
+ let senderDid = msg.from;
2629
+ const unsealed = unsealEnvelope(content);
2630
+ if (unsealed) {
2631
+ content = unsealed.msg;
2632
+ senderDid = unsealed.from;
2633
+ }
2508
2634
  let signatureValid = false;
2509
2635
  try {
2510
2636
  const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
2511
2637
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
2512
2638
  const envelopeStr = msg.envelope || JSON.stringify({
2513
- from: msg.from,
2639
+ from: senderDid,
2514
2640
  to: msg.to,
2515
2641
  timestamp: msg.timestamp,
2516
2642
  nonce: msg.nonce,
@@ -2526,9 +2652,9 @@ var VoidlyAgent = class _VoidlyAgent {
2526
2652
  }
2527
2653
  decrypted.push({
2528
2654
  id: msg.id,
2529
- from: msg.from,
2655
+ from: senderDid,
2530
2656
  to: msg.to,
2531
- content: (0, import_tweetnacl_util.encodeUTF8)(plaintext),
2657
+ content,
2532
2658
  contentType: msg.content_type,
2533
2659
  messageType: msg.message_type || "text",
2534
2660
  threadId: msg.thread_id,
@@ -3788,6 +3914,87 @@ var VoidlyAgent = class _VoidlyAgent {
3788
3914
  }
3789
3915
  throw lastError || new Error("Send failed after retries");
3790
3916
  }
3917
+ // ═══════════════════════════════════════════════════════════════════════════
3918
+ // OFFLINE QUEUE — Resilience against relay downtime
3919
+ // ═══════════════════════════════════════════════════════════════════════════
3920
+ /**
3921
+ * Drain the offline message queue — retry sending queued messages.
3922
+ * Call this when connectivity is restored.
3923
+ * Returns: number of messages successfully sent.
3924
+ */
3925
+ async drainQueue() {
3926
+ let sent = 0;
3927
+ let failed = 0;
3928
+ const remaining = [];
3929
+ for (const item of this._offlineQueue) {
3930
+ if (Date.now() - item.timestamp > 864e5) {
3931
+ failed++;
3932
+ continue;
3933
+ }
3934
+ try {
3935
+ await this.send(item.recipientDid, item.message, item.options);
3936
+ sent++;
3937
+ } catch {
3938
+ remaining.push(item);
3939
+ failed++;
3940
+ }
3941
+ }
3942
+ this._offlineQueue = remaining;
3943
+ return { sent, failed, remaining: remaining.length };
3944
+ }
3945
+ /** Number of messages waiting in the offline queue */
3946
+ get queueLength() {
3947
+ return this._offlineQueue.length;
3948
+ }
3949
+ // ═══════════════════════════════════════════════════════════════════════════
3950
+ // SECURITY REPORT — Transparent threat model
3951
+ // ═══════════════════════════════════════════════════════════════════════════
3952
+ /**
3953
+ * Returns what the relay can and cannot see about this agent.
3954
+ * Call this to understand your threat model. Total transparency.
3955
+ */
3956
+ threatModel() {
3957
+ return {
3958
+ relayCanSee: [
3959
+ "Your DID (public identifier)",
3960
+ "Who you message (recipient DIDs)",
3961
+ "When you message (timestamps)",
3962
+ "Message types (text, task-request, etc.)",
3963
+ "Thread structure (which messages are replies)",
3964
+ "Channel membership",
3965
+ "Capability registrations",
3966
+ "Online/offline status",
3967
+ "Approximate message size (even with padding, bounded to power-of-2)"
3968
+ ],
3969
+ relayCannotSee: [
3970
+ "Message content (E2E encrypted with NaCl box)",
3971
+ "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)",
3974
+ ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
3975
+ ],
3976
+ protections: [
3977
+ "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
3978
+ "Ed25519 signatures on every message",
3979
+ "TOFU key pinning (MitM detection on key change)",
3980
+ ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
3981
+ ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
3982
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
3983
+ "Auto-retry with exponential backoff",
3984
+ "Offline message queue"
3985
+ ],
3986
+ 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",
3989
+ "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
3990
+ "Metadata (who, when, thread structure) visible to relay operator",
3991
+ "Single relay architecture (no onion routing, no mix network)",
3992
+ "Ed25519 signatures are non-repudiable (no deniable messaging)",
3993
+ "No async key agreement (no X3DH prekeys)",
3994
+ "Polling-based (no WebSocket real-time transport)"
3995
+ ]
3996
+ };
3997
+ }
3791
3998
  };
3792
3999
  var Conversation = class {
3793
4000
  /** @internal */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@voidly/agent-sdk",
3
- "version": "1.9.0",
4
- "description": "E2E encrypted agent-to-agent communication SDK — true client-side encryption",
3
+ "version": "2.0.0",
4
+ "description": "E2E encrypted agent-to-agent communication SDK — padding, sealed sender, multi-relay fallback",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",