@voidly/agent-sdk 2.2.0 → 3.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
@@ -3972,19 +3972,29 @@ async function ratchetStep(chainKey) {
3972
3972
  );
3973
3973
  return { nextChainKey, messageKey };
3974
3974
  }
3975
- function sealEnvelope(senderDid, plaintext) {
3976
- return JSON.stringify({
3977
- v: 2,
3975
+ function sealEnvelope(senderDid, plaintext, meta) {
3976
+ const obj = {
3977
+ v: 3,
3978
3978
  from: senderDid,
3979
3979
  msg: plaintext,
3980
3980
  ts: (/* @__PURE__ */ new Date()).toISOString()
3981
- });
3981
+ };
3982
+ if (meta?.contentType && meta.contentType !== "text/plain") obj.ct = meta.contentType;
3983
+ if (meta?.messageType && meta.messageType !== "text") obj.mt = meta.messageType;
3984
+ if (meta?.threadId) obj.tid = meta.threadId;
3985
+ if (meta?.replyTo) obj.rto = meta.replyTo;
3986
+ return JSON.stringify(obj);
3982
3987
  }
3983
3988
  function unsealEnvelope(plaintext) {
3984
3989
  try {
3985
3990
  const parsed = JSON.parse(plaintext);
3986
- if (parsed.v === 2 && parsed.from && parsed.msg) {
3987
- return { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
3991
+ if ((parsed.v === 2 || parsed.v === 3) && parsed.from && parsed.msg) {
3992
+ const result = { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
3993
+ if (parsed.ct) result.contentType = parsed.ct;
3994
+ if (parsed.mt) result.messageType = parsed.mt;
3995
+ if (parsed.tid) result.threadId = parsed.tid;
3996
+ if (parsed.rto) result.replyTo = parsed.rto;
3997
+ return result;
3988
3998
  }
3989
3999
  return null;
3990
4000
  } catch {
@@ -3996,6 +4006,8 @@ var FLAG_PADDED = 1;
3996
4006
  var FLAG_SEALED = 2;
3997
4007
  var FLAG_RATCHET = 4;
3998
4008
  var FLAG_PQ = 8;
4009
+ var FLAG_DH_RATCHET = 16;
4010
+ var FLAG_DENIABLE = 32;
3999
4011
  function makeProtoHeader(flags, ratchetStep2) {
4000
4012
  return new Uint8Array([PROTO_MARKER, flags, ratchetStep2 >> 8 & 255, ratchetStep2 & 255]);
4001
4013
  }
@@ -4007,6 +4019,30 @@ function parseProtoHeader(data) {
4007
4019
  content: data.slice(4)
4008
4020
  };
4009
4021
  }
4022
+ async function kdfRK(rootKey, dhOutput) {
4023
+ const combined = new Uint8Array(rootKey.length + dhOutput.length);
4024
+ combined.set(rootKey, 0);
4025
+ combined.set(dhOutput, rootKey.length);
4026
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
4027
+ const prk2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined.buffer));
4028
+ const newRootKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array([...prk2, 1]).buffer));
4029
+ const newChainKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array([...prk2, 2]).buffer));
4030
+ return { newRootKey: newRootKey2, newChainKey: newChainKey2 };
4031
+ }
4032
+ const { createHash } = await import("crypto");
4033
+ const prk = new Uint8Array(createHash("sha256").update(Buffer.from(combined)).digest());
4034
+ const newRootKey = new Uint8Array(createHash("sha256").update(Buffer.from([...prk, 1])).digest());
4035
+ const newChainKey = new Uint8Array(createHash("sha256").update(Buffer.from([...prk, 2])).digest());
4036
+ return { newRootKey, newChainKey };
4037
+ }
4038
+ async function hmacSha256(key, data) {
4039
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
4040
+ const cryptoKey = await globalThis.crypto.subtle.importKey("raw", key.buffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
4041
+ return new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", cryptoKey, data.buffer));
4042
+ }
4043
+ const { createHmac } = await import("crypto");
4044
+ return new Uint8Array(createHmac("sha256", Buffer.from(key)).update(Buffer.from(data)).digest());
4045
+ }
4010
4046
  var MAX_SKIP = 200;
4011
4047
  var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
4012
4048
  function toBase58(bytes) {
@@ -4026,6 +4062,8 @@ function toBase58(bytes) {
4026
4062
  }
4027
4063
  var VoidlyAgent = class _VoidlyAgent {
4028
4064
  constructor(identity, config) {
4065
+ this._signedPrekey = null;
4066
+ this._signedPrekeyId = 0;
4029
4067
  this._pinnedDids = /* @__PURE__ */ new Set();
4030
4068
  this._listeners = /* @__PURE__ */ new Set();
4031
4069
  this._conversations = /* @__PURE__ */ new Map();
@@ -4034,6 +4072,14 @@ var VoidlyAgent = class _VoidlyAgent {
4034
4072
  this._identityCache = /* @__PURE__ */ new Map();
4035
4073
  this._seenMessageIds = /* @__PURE__ */ new Set();
4036
4074
  this._decryptFailCount = 0;
4075
+ // RPC handlers: method → handler function
4076
+ this._rpcHandlers = /* @__PURE__ */ new Map();
4077
+ // RPC pending responses: rpc_id → { resolve, reject, timer }
4078
+ this._rpcPending = /* @__PURE__ */ new Map();
4079
+ // Cover traffic state
4080
+ this._coverTrafficTimer = null;
4081
+ // RPC listener handle (started on first onInvoke)
4082
+ this._rpcListener = null;
4037
4083
  this.did = identity.did;
4038
4084
  this.apiKey = identity.apiKey;
4039
4085
  this.signingKeyPair = identity.signingKeyPair;
@@ -4047,6 +4093,10 @@ var VoidlyAgent = class _VoidlyAgent {
4047
4093
  this.requireSignatures = config?.requireSignatures || false;
4048
4094
  this.timeout = config?.timeout ?? 3e4;
4049
4095
  this.postQuantum = config?.postQuantum !== false;
4096
+ this.deniable = config?.deniable || false;
4097
+ this.doubleRatchet = config?.doubleRatchet !== false;
4098
+ this.jitterMs = config?.jitterMs || 0;
4099
+ this.longPoll = config?.longPoll !== false;
4050
4100
  this.mlkemPublicKey = identity.mlkemPublicKey || null;
4051
4101
  this.mlkemSecretKey = identity.mlkemSecretKey || null;
4052
4102
  }
@@ -4066,11 +4116,18 @@ var VoidlyAgent = class _VoidlyAgent {
4066
4116
  const kem = new MlKem768();
4067
4117
  [mlkemPk, mlkemSk] = await kem.generateKeyPair();
4068
4118
  }
4119
+ const signedPrekeyPair = import_tweetnacl.default.box.keyPair();
4120
+ const signedPrekeyId = 1;
4121
+ const prekeySignature = import_tweetnacl.default.sign.detached(signedPrekeyPair.publicKey, signingKeyPair.secretKey);
4069
4122
  const regBody = {
4070
4123
  name: options.name,
4071
4124
  capabilities: options.capabilities,
4072
4125
  signing_public_key: (0, import_tweetnacl_util.encodeBase64)(signingKeyPair.publicKey),
4073
- encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(encryptionKeyPair.publicKey)
4126
+ encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(encryptionKeyPair.publicKey),
4127
+ // X3DH signed prekey
4128
+ signed_prekey_public: (0, import_tweetnacl_util.encodeBase64)(signedPrekeyPair.publicKey),
4129
+ signed_prekey_signature: (0, import_tweetnacl_util.encodeBase64)(prekeySignature),
4130
+ signed_prekey_id: signedPrekeyId
4074
4131
  };
4075
4132
  if (mlkemPk) {
4076
4133
  regBody.mlkem_public_key = (0, import_tweetnacl_util.encodeBase64)(mlkemPk);
@@ -4086,7 +4143,7 @@ var VoidlyAgent = class _VoidlyAgent {
4086
4143
  throw new Error(`Registration failed: ${errMsg}`);
4087
4144
  }
4088
4145
  const data = await res.json();
4089
- return new _VoidlyAgent({
4146
+ const agent = new _VoidlyAgent({
4090
4147
  did: data.did,
4091
4148
  apiKey: data.api_key,
4092
4149
  signingKeyPair,
@@ -4094,6 +4151,9 @@ var VoidlyAgent = class _VoidlyAgent {
4094
4151
  mlkemPublicKey: mlkemPk,
4095
4152
  mlkemSecretKey: mlkemSk
4096
4153
  }, config);
4154
+ agent._signedPrekey = signedPrekeyPair;
4155
+ agent._signedPrekeyId = signedPrekeyId;
4156
+ return agent;
4097
4157
  }
4098
4158
  /**
4099
4159
  * Restore an agent from saved credentials.
@@ -4153,17 +4213,55 @@ var VoidlyAgent = class _VoidlyAgent {
4153
4213
  const sendChainKey = (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey);
4154
4214
  const recvChainKey = (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey);
4155
4215
  if (sendChainKey.length !== 32 || recvChainKey.length !== 32) continue;
4156
- agent._ratchetStates.set(pairId, {
4216
+ const state = {
4157
4217
  sendChainKey,
4158
4218
  sendStep: rs.sendStep || 0,
4159
4219
  recvChainKey,
4160
4220
  recvStep: rs.recvStep || 0,
4161
4221
  skippedKeys: /* @__PURE__ */ new Map()
4162
- });
4222
+ };
4223
+ if (rs.rootKey) {
4224
+ try {
4225
+ state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
4226
+ if (state.rootKey.length !== 32) state.rootKey = void 0;
4227
+ } catch {
4228
+ }
4229
+ }
4230
+ if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
4231
+ try {
4232
+ const sk = (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey);
4233
+ const pk = (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey);
4234
+ if (sk.length === 32 && pk.length === 32) {
4235
+ state.dhSendKeyPair = { publicKey: pk, secretKey: sk };
4236
+ }
4237
+ } catch {
4238
+ }
4239
+ }
4240
+ if (rs.dhRecvPubKey) {
4241
+ try {
4242
+ const pk = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
4243
+ if (pk.length === 32) state.dhRecvPubKey = pk;
4244
+ } catch {
4245
+ }
4246
+ }
4247
+ if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
4248
+ state.dhSkippedKeys = /* @__PURE__ */ new Map();
4249
+ agent._ratchetStates.set(pairId, state);
4163
4250
  } catch {
4164
4251
  }
4165
4252
  }
4166
4253
  }
4254
+ if (creds.signedPrekeySecret && creds.signedPrekeyPublic) {
4255
+ try {
4256
+ const sk = (0, import_tweetnacl_util.decodeBase64)(creds.signedPrekeySecret);
4257
+ const pk = (0, import_tweetnacl_util.decodeBase64)(creds.signedPrekeyPublic);
4258
+ if (sk.length === 32 && pk.length === 32) {
4259
+ agent._signedPrekey = { publicKey: pk, secretKey: sk };
4260
+ agent._signedPrekeyId = creds.signedPrekeyId || 0;
4261
+ }
4262
+ } catch {
4263
+ }
4264
+ }
4167
4265
  return agent;
4168
4266
  }
4169
4267
  /**
@@ -4173,12 +4271,20 @@ var VoidlyAgent = class _VoidlyAgent {
4173
4271
  exportCredentials() {
4174
4272
  const ratchetStates = {};
4175
4273
  for (const [pairId, state] of this._ratchetStates) {
4176
- ratchetStates[pairId] = {
4274
+ const rs = {
4177
4275
  sendChainKey: (0, import_tweetnacl_util.encodeBase64)(state.sendChainKey),
4178
4276
  sendStep: state.sendStep,
4179
4277
  recvChainKey: (0, import_tweetnacl_util.encodeBase64)(state.recvChainKey),
4180
4278
  recvStep: state.recvStep
4181
4279
  };
4280
+ if (state.rootKey) rs.rootKey = (0, import_tweetnacl_util.encodeBase64)(state.rootKey);
4281
+ if (state.dhSendKeyPair) {
4282
+ rs.dhSendSecretKey = (0, import_tweetnacl_util.encodeBase64)(state.dhSendKeyPair.secretKey);
4283
+ rs.dhSendPublicKey = (0, import_tweetnacl_util.encodeBase64)(state.dhSendKeyPair.publicKey);
4284
+ }
4285
+ if (state.dhRecvPubKey) rs.dhRecvPubKey = (0, import_tweetnacl_util.encodeBase64)(state.dhRecvPubKey);
4286
+ if (state.prevSendStep !== void 0) rs.prevSendStep = state.prevSendStep;
4287
+ ratchetStates[pairId] = rs;
4182
4288
  }
4183
4289
  return {
4184
4290
  did: this.did,
@@ -4189,7 +4295,12 @@ var VoidlyAgent = class _VoidlyAgent {
4189
4295
  encryptionPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.encryptionKeyPair.publicKey),
4190
4296
  ...Object.keys(ratchetStates).length > 0 ? { ratchetStates } : {},
4191
4297
  ...this.mlkemPublicKey ? { mlkemPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemPublicKey) } : {},
4192
- ...this.mlkemSecretKey ? { mlkemSecretKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemSecretKey) } : {}
4298
+ ...this.mlkemSecretKey ? { mlkemSecretKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemSecretKey) } : {},
4299
+ ...this._signedPrekey ? {
4300
+ signedPrekeySecret: (0, import_tweetnacl_util.encodeBase64)(this._signedPrekey.secretKey),
4301
+ signedPrekeyPublic: (0, import_tweetnacl_util.encodeBase64)(this._signedPrekey.publicKey),
4302
+ signedPrekeyId: this._signedPrekeyId
4303
+ } : {}
4193
4304
  };
4194
4305
  }
4195
4306
  /**
@@ -4239,7 +4350,12 @@ var VoidlyAgent = class _VoidlyAgent {
4239
4350
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
4240
4351
  let plaintext = message;
4241
4352
  if (useSealed) {
4242
- plaintext = sealEnvelope(this.did, message);
4353
+ plaintext = sealEnvelope(this.did, message, {
4354
+ contentType: options.contentType,
4355
+ messageType: options.messageType,
4356
+ threadId: options.threadId,
4357
+ replyTo: options.replyTo
4358
+ });
4243
4359
  }
4244
4360
  let contentBytes;
4245
4361
  if (usePadding) {
@@ -4250,9 +4366,10 @@ var VoidlyAgent = class _VoidlyAgent {
4250
4366
  const pairId = `${this.did}:${recipientDid}`;
4251
4367
  let state = this._ratchetStates.get(pairId);
4252
4368
  let pqCiphertext = null;
4369
+ let dhRatchetPub = null;
4253
4370
  if (!state) {
4254
4371
  const x25519Shared = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
4255
- let chainKey;
4372
+ let initialKey;
4256
4373
  if (this.postQuantum && profile.mlkem_public_key) {
4257
4374
  try {
4258
4375
  const recipientPqPk = (0, import_tweetnacl_util.decodeBase64)(profile.mlkem_public_key);
@@ -4262,22 +4379,44 @@ var VoidlyAgent = class _VoidlyAgent {
4262
4379
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4263
4380
  combined.set(x25519Shared, 0);
4264
4381
  combined.set(pqShared, x25519Shared.length);
4265
- chainKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4382
+ initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4266
4383
  } catch {
4267
- chainKey = x25519Shared;
4384
+ initialKey = x25519Shared;
4268
4385
  }
4269
4386
  } else {
4270
- chainKey = x25519Shared;
4387
+ initialKey = x25519Shared;
4388
+ }
4389
+ if (this.doubleRatchet) {
4390
+ const dhSendKeyPair = import_tweetnacl.default.box.keyPair();
4391
+ const dhOutput = import_tweetnacl.default.box.before(recipientPubKey, dhSendKeyPair.secretKey);
4392
+ const { newRootKey, newChainKey } = await kdfRK(initialKey, dhOutput);
4393
+ dhRatchetPub = dhSendKeyPair.publicKey;
4394
+ state = {
4395
+ sendChainKey: newChainKey,
4396
+ sendStep: 0,
4397
+ recvChainKey: initialKey,
4398
+ // Will be updated on first receive
4399
+ recvStep: 0,
4400
+ skippedKeys: /* @__PURE__ */ new Map(),
4401
+ // Double Ratchet state
4402
+ rootKey: newRootKey,
4403
+ dhSendKeyPair,
4404
+ dhRecvPubKey: void 0,
4405
+ prevSendStep: 0,
4406
+ dhSkippedKeys: /* @__PURE__ */ new Map()
4407
+ };
4408
+ } else {
4409
+ state = {
4410
+ sendChainKey: initialKey,
4411
+ sendStep: 0,
4412
+ recvChainKey: initialKey,
4413
+ recvStep: 0,
4414
+ skippedKeys: /* @__PURE__ */ new Map()
4415
+ };
4271
4416
  }
4272
- state = {
4273
- sendChainKey: chainKey,
4274
- sendStep: 0,
4275
- recvChainKey: chainKey,
4276
- // Will be synced on first receive
4277
- recvStep: 0,
4278
- skippedKeys: /* @__PURE__ */ new Map()
4279
- };
4280
4417
  this._ratchetStates.set(pairId, state);
4418
+ } else if (state.rootKey && state.dhSendKeyPair) {
4419
+ dhRatchetPub = state.dhSendKeyPair.publicKey;
4281
4420
  }
4282
4421
  const { nextChainKey, messageKey } = await ratchetStep(state.sendChainKey);
4283
4422
  state.sendChainKey = nextChainKey;
@@ -4287,6 +4426,8 @@ var VoidlyAgent = class _VoidlyAgent {
4287
4426
  if (usePadding) flags |= FLAG_PADDED;
4288
4427
  if (useSealed) flags |= FLAG_SEALED;
4289
4428
  if (pqCiphertext) flags |= FLAG_PQ;
4429
+ if (dhRatchetPub) flags |= FLAG_DH_RATCHET;
4430
+ if (this.deniable) flags |= FLAG_DENIABLE;
4290
4431
  const header = makeProtoHeader(flags, currentStep);
4291
4432
  const messageBytes = new Uint8Array(header.length + contentBytes.length);
4292
4433
  messageBytes.set(header, 0);
@@ -4296,6 +4437,10 @@ var VoidlyAgent = class _VoidlyAgent {
4296
4437
  if (!ciphertext) {
4297
4438
  throw new Error("Encryption failed");
4298
4439
  }
4440
+ if (this.jitterMs > 0) {
4441
+ const jitter = Math.random() * this.jitterMs;
4442
+ await new Promise((r) => setTimeout(r, jitter));
4443
+ }
4299
4444
  const envelopeObj = {
4300
4445
  from: this.did,
4301
4446
  to: recipientDid,
@@ -4307,20 +4452,32 @@ var VoidlyAgent = class _VoidlyAgent {
4307
4452
  if (pqCiphertext) {
4308
4453
  envelopeObj.pq_ciphertext = (0, import_tweetnacl_util.encodeBase64)(pqCiphertext);
4309
4454
  }
4455
+ if (dhRatchetPub) {
4456
+ envelopeObj.dh_ratchet_key = (0, import_tweetnacl_util.encodeBase64)(dhRatchetPub);
4457
+ envelopeObj.pn = state.prevSendStep || 0;
4458
+ }
4310
4459
  const envelopeData = JSON.stringify(envelopeObj);
4311
- const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
4460
+ let signature;
4461
+ if (this.deniable) {
4462
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
4463
+ signature = await hmacSha256(sharedSecret, (0, import_tweetnacl_util.decodeUTF8)(envelopeData));
4464
+ } else {
4465
+ signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
4466
+ }
4312
4467
  const payload = {
4313
4468
  to: recipientDid,
4314
4469
  ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
4315
4470
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
4316
4471
  signature: (0, import_tweetnacl_util.encodeBase64)(signature),
4317
4472
  envelope: envelopeData,
4318
- content_type: options.contentType || "text/plain",
4319
- message_type: options.messageType || "text",
4320
- thread_id: options.threadId,
4321
- reply_to: options.replyTo,
4322
4473
  ttl: options.ttl
4323
4474
  };
4475
+ if (!useSealed) {
4476
+ payload.content_type = options.contentType || "text/plain";
4477
+ payload.message_type = options.messageType || "text";
4478
+ payload.thread_id = options.threadId;
4479
+ payload.reply_to = options.replyTo;
4480
+ }
4324
4481
  const relays = [this.baseUrl, ...this.fallbackRelays];
4325
4482
  let lastError = null;
4326
4483
  for (const relay of relays) {
@@ -4371,7 +4528,7 @@ var VoidlyAgent = class _VoidlyAgent {
4371
4528
  if (options.contentType) params.set("content_type", options.contentType);
4372
4529
  if (options.messageType) params.set("message_type", options.messageType);
4373
4530
  if (options.unreadOnly) params.set("unread", "true");
4374
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/raw?${params}`, {
4531
+ const res = await this._resilientFetch(`/v1/agent/receive/raw?${params}`, {
4375
4532
  headers: { "X-Agent-Key": this.apiKey }
4376
4533
  });
4377
4534
  if (!res.ok) {
@@ -4383,12 +4540,28 @@ var VoidlyAgent = class _VoidlyAgent {
4383
4540
  for (const msg of data.messages) {
4384
4541
  try {
4385
4542
  if (this._seenMessageIds.has(msg.id)) continue;
4386
- const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4543
+ let senderEncPub;
4544
+ let senderSignPubBytes = null;
4545
+ if (msg.sender_encryption_key) {
4546
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4547
+ if (msg.sender_signing_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4548
+ } else if (msg.envelope) {
4549
+ const env = JSON.parse(msg.envelope);
4550
+ const senderProfile = await this.getIdentity(env.from);
4551
+ if (!senderProfile) continue;
4552
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(senderProfile.encryption_public_key);
4553
+ if (senderProfile.signing_public_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(senderProfile.signing_public_key);
4554
+ } else {
4555
+ continue;
4556
+ }
4387
4557
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
4388
4558
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
4389
4559
  let rawPlaintext = null;
4390
4560
  let envelopeRatchetStep = 0;
4391
4561
  let envelopePqCiphertext = null;
4562
+ let envelopeDhRatchetKey = null;
4563
+ let envelopePn = 0;
4564
+ let envelopeDeniable = false;
4392
4565
  if (msg.envelope) {
4393
4566
  try {
4394
4567
  const env = JSON.parse(msg.envelope);
@@ -4398,6 +4571,12 @@ var VoidlyAgent = class _VoidlyAgent {
4398
4571
  if (typeof env.pq_ciphertext === "string") {
4399
4572
  envelopePqCiphertext = env.pq_ciphertext;
4400
4573
  }
4574
+ if (typeof env.dh_ratchet_key === "string") {
4575
+ envelopeDhRatchetKey = env.dh_ratchet_key;
4576
+ }
4577
+ if (typeof env.pn === "number") {
4578
+ envelopePn = env.pn;
4579
+ }
4401
4580
  } catch {
4402
4581
  }
4403
4582
  }
@@ -4406,7 +4585,7 @@ var VoidlyAgent = class _VoidlyAgent {
4406
4585
  let state = this._ratchetStates.get(pairId);
4407
4586
  if (!state) {
4408
4587
  const x25519Shared = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4409
- let chainKey;
4588
+ let initialKey;
4410
4589
  if (envelopePqCiphertext && this.mlkemSecretKey) {
4411
4590
  try {
4412
4591
  const pqCt = (0, import_tweetnacl_util.decodeBase64)(envelopePqCiphertext);
@@ -4415,26 +4594,80 @@ var VoidlyAgent = class _VoidlyAgent {
4415
4594
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4416
4595
  combined.set(x25519Shared, 0);
4417
4596
  combined.set(pqShared, x25519Shared.length);
4418
- chainKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4597
+ initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4419
4598
  } catch {
4420
- chainKey = x25519Shared;
4599
+ initialKey = x25519Shared;
4421
4600
  }
4422
4601
  } else {
4423
- chainKey = x25519Shared;
4602
+ initialKey = x25519Shared;
4603
+ }
4604
+ if (envelopeDhRatchetKey && this.doubleRatchet) {
4605
+ const senderDhPub = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
4606
+ const dhOutput = import_tweetnacl.default.box.before(senderDhPub, this.encryptionKeyPair.secretKey);
4607
+ const { newRootKey, newChainKey } = await kdfRK(initialKey, dhOutput);
4608
+ state = {
4609
+ sendChainKey: initialKey,
4610
+ sendStep: 0,
4611
+ recvChainKey: newChainKey,
4612
+ recvStep: 0,
4613
+ skippedKeys: /* @__PURE__ */ new Map(),
4614
+ rootKey: newRootKey,
4615
+ dhSendKeyPair: void 0,
4616
+ dhRecvPubKey: senderDhPub,
4617
+ prevSendStep: 0,
4618
+ dhSkippedKeys: /* @__PURE__ */ new Map()
4619
+ };
4620
+ } else {
4621
+ state = {
4622
+ sendChainKey: initialKey,
4623
+ sendStep: 0,
4624
+ recvChainKey: initialKey,
4625
+ recvStep: 0,
4626
+ skippedKeys: /* @__PURE__ */ new Map()
4627
+ };
4424
4628
  }
4425
- state = {
4426
- sendChainKey: chainKey,
4427
- // Our sending chain to this peer
4428
- sendStep: 0,
4429
- recvChainKey: chainKey,
4430
- // Their sending chain (our receiving)
4431
- recvStep: 0,
4432
- skippedKeys: /* @__PURE__ */ new Map()
4433
- };
4434
4629
  this._ratchetStates.set(pairId, state);
4630
+ } else if (envelopeDhRatchetKey && state.rootKey) {
4631
+ const senderDhPub = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
4632
+ const currentDhRecv = state.dhRecvPubKey;
4633
+ if (!currentDhRecv || (0, import_tweetnacl_util.encodeBase64)(senderDhPub) !== (0, import_tweetnacl_util.encodeBase64)(currentDhRecv)) {
4634
+ if (envelopePn > state.recvStep) {
4635
+ let ck = state.recvChainKey;
4636
+ for (let i = state.recvStep + 1; i <= envelopePn && i - state.recvStep <= MAX_SKIP; i++) {
4637
+ const { nextChainKey, messageKey: skippedMk } = await ratchetStep(ck);
4638
+ const skipKey = `${currentDhRecv ? (0, import_tweetnacl_util.encodeBase64)(currentDhRecv) : "init"}:${i}`;
4639
+ if (!state.dhSkippedKeys) state.dhSkippedKeys = /* @__PURE__ */ new Map();
4640
+ state.dhSkippedKeys.set(skipKey, skippedMk);
4641
+ ck = nextChainKey;
4642
+ if (state.dhSkippedKeys.size > MAX_SKIP) {
4643
+ const oldest = state.dhSkippedKeys.keys().next().value;
4644
+ if (oldest !== void 0) state.dhSkippedKeys.delete(oldest);
4645
+ }
4646
+ }
4647
+ }
4648
+ state.dhRecvPubKey = senderDhPub;
4649
+ const myKey = state.dhSendKeyPair || this.encryptionKeyPair;
4650
+ const dhOutput1 = import_tweetnacl.default.box.before(senderDhPub, myKey.secretKey);
4651
+ const kdf1 = await kdfRK(state.rootKey, dhOutput1);
4652
+ state.rootKey = kdf1.newRootKey;
4653
+ state.recvChainKey = kdf1.newChainKey;
4654
+ state.recvStep = 0;
4655
+ state.prevSendStep = state.sendStep;
4656
+ state.dhSendKeyPair = import_tweetnacl.default.box.keyPair();
4657
+ state.sendStep = 0;
4658
+ const dhOutput2 = import_tweetnacl.default.box.before(senderDhPub, state.dhSendKeyPair.secretKey);
4659
+ const kdf2 = await kdfRK(state.rootKey, dhOutput2);
4660
+ state.rootKey = kdf2.newRootKey;
4661
+ state.sendChainKey = kdf2.newChainKey;
4662
+ }
4435
4663
  }
4436
4664
  const targetStep = envelopeRatchetStep;
4437
- if (state.skippedKeys.has(targetStep)) {
4665
+ const dhSkipKey = envelopeDhRatchetKey ? `${envelopeDhRatchetKey}:${targetStep}` : `init:${targetStep}`;
4666
+ if (state.dhSkippedKeys?.has(dhSkipKey)) {
4667
+ const mk = state.dhSkippedKeys.get(dhSkipKey);
4668
+ rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
4669
+ state.dhSkippedKeys.delete(dhSkipKey);
4670
+ } else if (state.skippedKeys.has(targetStep)) {
4438
4671
  const mk = state.skippedKeys.get(targetStep);
4439
4672
  rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
4440
4673
  state.skippedKeys.delete(targetStep);
@@ -4488,16 +4721,23 @@ var VoidlyAgent = class _VoidlyAgent {
4488
4721
  }
4489
4722
  let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
4490
4723
  let senderDid = msg.from;
4724
+ let innerContentType;
4725
+ let innerMessageType;
4726
+ let innerThreadId;
4727
+ let innerReplyTo;
4491
4728
  if (wasSealed || !proto) {
4492
4729
  const unsealed = unsealEnvelope(content);
4493
4730
  if (unsealed) {
4494
4731
  content = unsealed.msg;
4495
4732
  senderDid = unsealed.from;
4733
+ innerContentType = unsealed.contentType;
4734
+ innerMessageType = unsealed.messageType;
4735
+ innerThreadId = unsealed.threadId;
4736
+ innerReplyTo = unsealed.replyTo;
4496
4737
  }
4497
4738
  }
4498
4739
  let signatureValid = false;
4499
4740
  try {
4500
- const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4501
4741
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
4502
4742
  const envelopeStr = msg.envelope || JSON.stringify({
4503
4743
  from: senderDid,
@@ -4506,11 +4746,21 @@ var VoidlyAgent = class _VoidlyAgent {
4506
4746
  nonce: msg.nonce,
4507
4747
  ciphertext_hash: await sha256(msg.ciphertext)
4508
4748
  });
4509
- signatureValid = import_tweetnacl.default.sign.detached.verify(
4510
- (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4511
- signatureBytes,
4512
- senderSignPub
4513
- );
4749
+ if (signatureBytes.length === 32) {
4750
+ const sharedSecret = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4751
+ const expectedHmac = await hmacSha256(sharedSecret, (0, import_tweetnacl_util.decodeUTF8)(envelopeStr));
4752
+ if (expectedHmac.length === signatureBytes.length) {
4753
+ let diff = 0;
4754
+ for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
4755
+ signatureValid = diff === 0;
4756
+ }
4757
+ } else if (senderSignPubBytes) {
4758
+ signatureValid = import_tweetnacl.default.sign.detached.verify(
4759
+ (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4760
+ signatureBytes,
4761
+ senderSignPubBytes
4762
+ );
4763
+ }
4514
4764
  } catch {
4515
4765
  signatureValid = false;
4516
4766
  }
@@ -4528,10 +4778,11 @@ var VoidlyAgent = class _VoidlyAgent {
4528
4778
  from: senderDid,
4529
4779
  to: msg.to,
4530
4780
  content,
4531
- contentType: msg.content_type,
4532
- messageType: msg.message_type || "text",
4533
- threadId: msg.thread_id,
4534
- replyTo: msg.reply_to,
4781
+ // v3: prefer metadata from inside ciphertext (relay can't see it)
4782
+ contentType: innerContentType || msg.content_type || "text/plain",
4783
+ messageType: innerMessageType || msg.message_type || "text",
4784
+ threadId: innerThreadId || msg.thread_id || null,
4785
+ replyTo: innerReplyTo || msg.reply_to || null,
4535
4786
  signatureValid,
4536
4787
  timestamp: msg.timestamp,
4537
4788
  expiresAt: msg.expires_at
@@ -4592,7 +4843,7 @@ var VoidlyAgent = class _VoidlyAgent {
4592
4843
  if (cached && Date.now() - cached.cachedAt < 3e5) {
4593
4844
  return cached.profile;
4594
4845
  }
4595
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/identity/${did}`);
4846
+ const res = await this._resilientFetch(`/v1/agent/identity/${did}`);
4596
4847
  if (!res.ok) return null;
4597
4848
  const profile = await res.json();
4598
4849
  this._identityCache.set(did, { profile, cachedAt: Date.now() });
@@ -4610,7 +4861,7 @@ var VoidlyAgent = class _VoidlyAgent {
4610
4861
  if (options.query) params.set("query", options.query);
4611
4862
  if (options.capability) params.set("capability", options.capability);
4612
4863
  if (options.limit) params.set("limit", String(options.limit));
4613
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/discover?${params}`);
4864
+ const res = await this._resilientFetch(`/v1/agent/discover?${params}`);
4614
4865
  if (!res.ok) return [];
4615
4866
  const data = await res.json();
4616
4867
  return data.agents;
@@ -4704,22 +4955,35 @@ var VoidlyAgent = class _VoidlyAgent {
4704
4955
  async rotateKeys() {
4705
4956
  const newSigningKeyPair = import_tweetnacl.default.sign.keyPair();
4706
4957
  const newEncryptionKeyPair = import_tweetnacl.default.box.keyPair();
4958
+ const newSignedPrekey = import_tweetnacl.default.box.keyPair();
4959
+ const newSignedPrekeyId = (this._signedPrekeyId || 0) + 1;
4960
+ const signedPrekeySignature = import_tweetnacl.default.sign.detached(newSignedPrekey.publicKey, newSigningKeyPair.secretKey);
4961
+ const body = {
4962
+ signing_public_key: (0, import_tweetnacl_util.encodeBase64)(newSigningKeyPair.publicKey),
4963
+ encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(newEncryptionKeyPair.publicKey),
4964
+ signed_prekey_public: (0, import_tweetnacl_util.encodeBase64)(newSignedPrekey.publicKey),
4965
+ signed_prekey_signature: (0, import_tweetnacl_util.encodeBase64)(signedPrekeySignature),
4966
+ signed_prekey_id: newSignedPrekeyId
4967
+ };
4968
+ if (this.postQuantum && this.mlkemPublicKey) {
4969
+ body.mlkem_public_key = this.mlkemPublicKey;
4970
+ }
4707
4971
  const res = await this._timedFetch(`${this.baseUrl}/v1/agent/rotate-keys`, {
4708
4972
  method: "POST",
4709
4973
  headers: {
4710
4974
  "Content-Type": "application/json",
4711
4975
  "X-Agent-Key": this.apiKey
4712
4976
  },
4713
- body: JSON.stringify({
4714
- signing_public_key: (0, import_tweetnacl_util.encodeBase64)(newSigningKeyPair.publicKey),
4715
- encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(newEncryptionKeyPair.publicKey)
4716
- })
4977
+ body: JSON.stringify(body)
4717
4978
  });
4718
4979
  if (!res.ok) {
4719
4980
  throw new Error("Key rotation failed");
4720
4981
  }
4721
4982
  this.signingKeyPair = newSigningKeyPair;
4722
4983
  this.encryptionKeyPair = newEncryptionKeyPair;
4984
+ this._signedPrekey = newSignedPrekey;
4985
+ this._signedPrekeyId = newSignedPrekeyId;
4986
+ await this.uploadPrekeys(10);
4723
4987
  }
4724
4988
  // ─── Channels (Encrypted AI Forum) ──────────────────────────────────────────
4725
4989
  /**
@@ -5597,6 +5861,158 @@ var VoidlyAgent = class _VoidlyAgent {
5597
5861
  return res.json();
5598
5862
  }
5599
5863
  // ═══════════════════════════════════════════════════════════════════════════
5864
+ // X3DH — Async Key Agreement (prekey bundles for offline agents)
5865
+ // ═══════════════════════════════════════════════════════════════════════════
5866
+ /**
5867
+ * Upload prekey bundle for X3DH async key agreement.
5868
+ * Other agents can fetch your prekeys and establish encrypted sessions
5869
+ * even while you're offline.
5870
+ *
5871
+ * @param count Number of one-time prekeys to upload (default: 20)
5872
+ */
5873
+ async uploadPrekeys(count = 20) {
5874
+ const prekeys = [];
5875
+ for (let i = 0; i < count; i++) {
5876
+ const kp = import_tweetnacl.default.box.keyPair();
5877
+ prekeys.push({
5878
+ id: Date.now() + i,
5879
+ public_key: (0, import_tweetnacl_util.encodeBase64)(kp.publicKey),
5880
+ secretKey: kp.secretKey
5881
+ });
5882
+ }
5883
+ const newPrekey = import_tweetnacl.default.box.keyPair();
5884
+ this._signedPrekeyId++;
5885
+ const prekeySignature = import_tweetnacl.default.sign.detached(newPrekey.publicKey, this.signingKeyPair.secretKey);
5886
+ this._signedPrekey = newPrekey;
5887
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/prekeys`, {
5888
+ method: "POST",
5889
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5890
+ body: JSON.stringify({
5891
+ prekeys: prekeys.map((pk) => ({ id: pk.id, public_key: pk.public_key })),
5892
+ signed_prekey: {
5893
+ public_key: (0, import_tweetnacl_util.encodeBase64)(newPrekey.publicKey),
5894
+ signature: (0, import_tweetnacl_util.encodeBase64)(prekeySignature),
5895
+ id: this._signedPrekeyId
5896
+ }
5897
+ })
5898
+ });
5899
+ if (!res.ok) throw new Error(`Prekey upload failed: ${res.status}`);
5900
+ return await res.json();
5901
+ }
5902
+ /**
5903
+ * Fetch another agent's prekey bundle for X3DH key agreement.
5904
+ * Use this to establish an encrypted session with an offline agent.
5905
+ */
5906
+ async fetchPrekeys(did) {
5907
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/prekeys/${encodeURIComponent(did)}`);
5908
+ if (!res.ok) return null;
5909
+ return await res.json();
5910
+ }
5911
+ // ═══════════════════════════════════════════════════════════════════════════
5912
+ // CLIENT-SIDE CHANNEL ENCRYPTION
5913
+ // ═══════════════════════════════════════════════════════════════════════════
5914
+ /**
5915
+ * Create a channel with client-side encryption.
5916
+ * The channel symmetric key is generated locally and encrypted per-member.
5917
+ * The relay NEVER sees the plaintext channel key — true E2E for groups.
5918
+ */
5919
+ async createEncryptedChannel(options) {
5920
+ const channelKey = import_tweetnacl.default.randomBytes(32);
5921
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/channels`, {
5922
+ method: "POST",
5923
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5924
+ body: JSON.stringify(options)
5925
+ });
5926
+ if (!res.ok) {
5927
+ const err = await res.json().catch(() => ({}));
5928
+ throw new Error(`Channel creation failed: ${err.error || res.statusText}`);
5929
+ }
5930
+ const channel = await res.json();
5931
+ const selfNonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
5932
+ const selfEncrypted = import_tweetnacl.default.box(channelKey, selfNonce, this.encryptionKeyPair.publicKey, this.encryptionKeyPair.secretKey);
5933
+ await this.memorySet("channel-keys", channel.id, {
5934
+ key: (0, import_tweetnacl_util.encodeBase64)(channelKey),
5935
+ nonce: (0, import_tweetnacl_util.encodeBase64)(selfNonce)
5936
+ });
5937
+ return { ...channel, channelKey };
5938
+ }
5939
+ /**
5940
+ * Post a client-side encrypted message to a channel.
5941
+ * Uses the channel's shared symmetric key — relay never sees plaintext.
5942
+ *
5943
+ * @param channelId Channel ID
5944
+ * @param message Plaintext message
5945
+ * @param channelKey 32-byte symmetric channel key (from createEncryptedChannel or received via invite)
5946
+ */
5947
+ async postEncrypted(channelId, message, channelKey) {
5948
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
5949
+ const ciphertext = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(message), nonce, channelKey);
5950
+ const sigPayload = new Uint8Array([...ciphertext, ...nonce]);
5951
+ const signature = import_tweetnacl.default.sign.detached(sigPayload, this.signingKeyPair.secretKey);
5952
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/channels/${channelId}/messages`, {
5953
+ method: "POST",
5954
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5955
+ body: JSON.stringify({
5956
+ message: JSON.stringify({
5957
+ v: 3,
5958
+ ct: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
5959
+ n: (0, import_tweetnacl_util.encodeBase64)(nonce),
5960
+ sig: (0, import_tweetnacl_util.encodeBase64)(signature),
5961
+ from: this.did
5962
+ })
5963
+ })
5964
+ });
5965
+ if (!res.ok) throw new Error(`Post failed: ${res.status}`);
5966
+ return await res.json();
5967
+ }
5968
+ /**
5969
+ * Read and decrypt channel messages using client-side channel key.
5970
+ * Ignores server-side encryption entirely — true E2E.
5971
+ *
5972
+ * @param channelId Channel ID
5973
+ * @param channelKey 32-byte symmetric channel key
5974
+ */
5975
+ async readEncrypted(channelId, channelKey, options) {
5976
+ const raw = await this.readChannel(channelId, options);
5977
+ const decrypted = [];
5978
+ for (const msg of raw.messages) {
5979
+ try {
5980
+ const parsed = JSON.parse(typeof msg.content === "string" ? msg.content : msg.message || "");
5981
+ if (parsed.v === 3 && parsed.ct && parsed.n) {
5982
+ const ct = (0, import_tweetnacl_util.decodeBase64)(parsed.ct);
5983
+ const nonce = (0, import_tweetnacl_util.decodeBase64)(parsed.n);
5984
+ const plain = import_tweetnacl.default.secretbox.open(ct, nonce, channelKey);
5985
+ if (plain) {
5986
+ let sigValid = false;
5987
+ if (parsed.sig && parsed.from) {
5988
+ try {
5989
+ const profile = await this.getIdentity(parsed.from);
5990
+ if (profile) {
5991
+ const sigPayload = new Uint8Array([...ct, ...nonce]);
5992
+ sigValid = import_tweetnacl.default.sign.detached.verify(
5993
+ sigPayload,
5994
+ (0, import_tweetnacl_util.decodeBase64)(parsed.sig),
5995
+ (0, import_tweetnacl_util.decodeBase64)(profile.signing_public_key)
5996
+ );
5997
+ }
5998
+ } catch {
5999
+ }
6000
+ }
6001
+ decrypted.push({
6002
+ id: msg.id,
6003
+ from: parsed.from || msg.author || "unknown",
6004
+ content: (0, import_tweetnacl_util.encodeUTF8)(plain),
6005
+ timestamp: msg.created_at || msg.timestamp,
6006
+ signatureValid: sigValid
6007
+ });
6008
+ }
6009
+ }
6010
+ } catch {
6011
+ }
6012
+ }
6013
+ return { messages: decrypted, count: decrypted.length };
6014
+ }
6015
+ // ═══════════════════════════════════════════════════════════════════════════
5600
6016
  // LISTEN — Event-Driven Message Receiving
5601
6017
  // ═══════════════════════════════════════════════════════════════════════════
5602
6018
  /**
@@ -5663,20 +6079,54 @@ var VoidlyAgent = class _VoidlyAgent {
5663
6079
  this.ping().catch(() => {
5664
6080
  });
5665
6081
  }
6082
+ const useLongPoll = this.longPoll;
5666
6083
  const poll = async () => {
5667
6084
  if (!active || options.signal?.aborted) {
5668
6085
  handle.stop();
5669
6086
  return;
5670
6087
  }
5671
6088
  try {
5672
- const messages = await this.receive({
5673
- since: lastSeen,
5674
- from: options.from,
5675
- threadId: options.threadId,
5676
- messageType: options.messageType,
5677
- unreadOnly,
5678
- limit: 50
5679
- });
6089
+ let messages;
6090
+ if (useLongPoll) {
6091
+ const params = new URLSearchParams({ timeout: "25" });
6092
+ if (lastSeen) params.set("since", lastSeen);
6093
+ if (options.from) params.set("from", options.from);
6094
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/poll?${params}`, {
6095
+ headers: { "X-Agent-Key": this.apiKey }
6096
+ });
6097
+ if (res.ok) {
6098
+ const data = await res.json();
6099
+ messages = [];
6100
+ for (const raw of data.messages) {
6101
+ try {
6102
+ if (this._seenMessageIds.has(raw.id)) continue;
6103
+ this._seenMessageIds.add(raw.id);
6104
+ } catch {
6105
+ }
6106
+ }
6107
+ if (data.messages.length > 0) {
6108
+ messages = await this.receive({
6109
+ since: lastSeen,
6110
+ from: options.from,
6111
+ threadId: options.threadId,
6112
+ messageType: options.messageType,
6113
+ unreadOnly,
6114
+ limit: 50
6115
+ });
6116
+ }
6117
+ } else {
6118
+ messages = [];
6119
+ }
6120
+ } else {
6121
+ messages = await this.receive({
6122
+ since: lastSeen,
6123
+ from: options.from,
6124
+ threadId: options.threadId,
6125
+ messageType: options.messageType,
6126
+ unreadOnly,
6127
+ limit: 50
6128
+ });
6129
+ }
5680
6130
  if (messages.length > 0) {
5681
6131
  consecutiveEmpty = 0;
5682
6132
  if (adaptive) currentInterval = Math.max(interval / 2, 500);
@@ -5694,7 +6144,7 @@ var VoidlyAgent = class _VoidlyAgent {
5694
6144
  lastSeen = messages[messages.length - 1].timestamp;
5695
6145
  } else {
5696
6146
  consecutiveEmpty++;
5697
- if (adaptive && consecutiveEmpty > 3) {
6147
+ if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
5698
6148
  currentInterval = Math.min(currentInterval * 1.5, interval * 4);
5699
6149
  }
5700
6150
  }
@@ -5703,7 +6153,7 @@ var VoidlyAgent = class _VoidlyAgent {
5703
6153
  currentInterval = Math.min(currentInterval * 2, interval * 8);
5704
6154
  }
5705
6155
  if (active && !options.signal?.aborted) {
5706
- timer = setTimeout(poll, currentInterval);
6156
+ timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
5707
6157
  }
5708
6158
  };
5709
6159
  poll();
@@ -5765,6 +6215,285 @@ var VoidlyAgent = class _VoidlyAgent {
5765
6215
  listener.stop();
5766
6216
  }
5767
6217
  this._listeners.clear();
6218
+ if (this._rpcListener) {
6219
+ this._rpcListener.stop();
6220
+ this._rpcListener = null;
6221
+ }
6222
+ for (const [id, pending] of this._rpcPending) {
6223
+ clearTimeout(pending.timer);
6224
+ pending.reject(new Error("Agent stopped"));
6225
+ }
6226
+ this._rpcPending.clear();
6227
+ this.disableCoverTraffic();
6228
+ }
6229
+ // ═══════════════════════════════════════════════════════════════════════════
6230
+ // AGENT RPC — Synchronous Function Invocation Between Agents
6231
+ // ═══════════════════════════════════════════════════════════════════════════
6232
+ /**
6233
+ * Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
6234
+ * The remote agent must have registered a handler via `onInvoke()`.
6235
+ *
6236
+ * @example
6237
+ * ```ts
6238
+ * // Call a translator agent
6239
+ * const result = await agent.invoke('did:voidly:translator', 'translate', {
6240
+ * text: 'Hello, world!',
6241
+ * to: 'ja',
6242
+ * });
6243
+ * console.log(result.translation); // こんにちは
6244
+ *
6245
+ * // With timeout
6246
+ * const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
6247
+ * ```
6248
+ */
6249
+ async invoke(targetDid, method, params = {}, timeoutMs = 3e4) {
6250
+ const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6251
+ return new Promise(async (resolve, reject) => {
6252
+ const timer = setTimeout(() => {
6253
+ this._rpcPending.delete(rpcId);
6254
+ reject(new Error(`RPC timeout: ${method}@${targetDid} after ${timeoutMs}ms`));
6255
+ }, timeoutMs);
6256
+ this._rpcPending.set(rpcId, { resolve, reject, timer });
6257
+ try {
6258
+ await this.send(targetDid, JSON.stringify({
6259
+ jsonrpc: "2.0",
6260
+ method,
6261
+ params,
6262
+ id: rpcId
6263
+ }), { messageType: "rpc-request", threadId: rpcId });
6264
+ } catch (err) {
6265
+ clearTimeout(timer);
6266
+ this._rpcPending.delete(rpcId);
6267
+ reject(err);
6268
+ }
6269
+ });
6270
+ }
6271
+ /**
6272
+ * Register a handler for incoming RPC invocations.
6273
+ * When another agent calls `invoke(yourDid, method, params)`, your handler runs.
6274
+ *
6275
+ * @example
6276
+ * ```ts
6277
+ * // Register a translation capability
6278
+ * agent.onInvoke('translate', async (params, callerDid) => {
6279
+ * const result = await myTranslateFunction(params.text, params.to);
6280
+ * return { translation: result };
6281
+ * });
6282
+ *
6283
+ * // Register a search capability
6284
+ * agent.onInvoke('search', async (params) => {
6285
+ * return { results: await searchDatabase(params.query) };
6286
+ * });
6287
+ * ```
6288
+ */
6289
+ onInvoke(method, handler) {
6290
+ this._rpcHandlers.set(method, handler);
6291
+ this._ensureRpcListener();
6292
+ }
6293
+ /**
6294
+ * Remove an RPC handler.
6295
+ */
6296
+ offInvoke(method) {
6297
+ this._rpcHandlers.delete(method);
6298
+ if (this._rpcHandlers.size === 0 && this._rpcListener) {
6299
+ this._rpcListener.stop();
6300
+ this._rpcListener = null;
6301
+ }
6302
+ }
6303
+ /** @internal Start listening for RPC requests and responses */
6304
+ _ensureRpcListener() {
6305
+ if (this._rpcListener) return;
6306
+ this._rpcListener = this.listen(async (msg) => {
6307
+ try {
6308
+ const payload = JSON.parse(msg.content);
6309
+ if (payload.jsonrpc !== "2.0") return;
6310
+ if (payload.id && (payload.result !== void 0 || payload.error)) {
6311
+ const pending = this._rpcPending.get(payload.id);
6312
+ if (pending) {
6313
+ clearTimeout(pending.timer);
6314
+ this._rpcPending.delete(payload.id);
6315
+ if (payload.error) {
6316
+ pending.reject(new Error(payload.error.message || "RPC error"));
6317
+ } else {
6318
+ pending.resolve(payload.result);
6319
+ }
6320
+ }
6321
+ return;
6322
+ }
6323
+ if (payload.method && payload.id) {
6324
+ const handler = this._rpcHandlers.get(payload.method);
6325
+ if (!handler) {
6326
+ await this.send(msg.from, JSON.stringify({
6327
+ jsonrpc: "2.0",
6328
+ id: payload.id,
6329
+ error: { code: -32601, message: `Method not found: ${payload.method}` }
6330
+ }), { messageType: "rpc-response", threadId: payload.id });
6331
+ return;
6332
+ }
6333
+ try {
6334
+ const result = await handler(payload.params || {}, msg.from);
6335
+ await this.send(msg.from, JSON.stringify({
6336
+ jsonrpc: "2.0",
6337
+ id: payload.id,
6338
+ result
6339
+ }), { messageType: "rpc-response", threadId: payload.id });
6340
+ } catch (err) {
6341
+ await this.send(msg.from, JSON.stringify({
6342
+ jsonrpc: "2.0",
6343
+ id: payload.id,
6344
+ error: { code: -32e3, message: err.message || "Handler error" }
6345
+ }), { messageType: "rpc-response", threadId: payload.id });
6346
+ }
6347
+ }
6348
+ } catch {
6349
+ }
6350
+ }, { interval: 500, adaptive: false, heartbeat: false });
6351
+ }
6352
+ // ═══════════════════════════════════════════════════════════════════════════
6353
+ // P2P DIRECT MODE — Bypass Relay When Possible
6354
+ // ═══════════════════════════════════════════════════════════════════════════
6355
+ /**
6356
+ * Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
6357
+ * The relay never sees the message — true peer-to-peer encrypted delivery.
6358
+ *
6359
+ * Falls back to relay-based send if direct delivery fails.
6360
+ *
6361
+ * @example
6362
+ * ```ts
6363
+ * // Try direct first, fall back to relay
6364
+ * const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
6365
+ * console.log(result.direct); // true if delivered directly, false if via relay
6366
+ * ```
6367
+ */
6368
+ async sendDirect(recipientDid, message, options = {}) {
6369
+ try {
6370
+ const profile = await this.getIdentity(recipientDid);
6371
+ if (profile) {
6372
+ const webhookRes = await this._timedFetch(
6373
+ `${this.baseUrl}/v1/agent/identity/${recipientDid}`,
6374
+ { headers: { "X-Agent-Key": this.apiKey } }
6375
+ );
6376
+ if (webhookRes.ok) {
6377
+ const data = await webhookRes.json();
6378
+ const webhookUrl = data.webhook_url;
6379
+ if (webhookUrl) {
6380
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
6381
+ const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
6382
+ const plaintext = (0, import_tweetnacl_util.decodeUTF8)(message);
6383
+ const ciphertext = import_tweetnacl.default.box(plaintext, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
6384
+ const envelope = JSON.stringify({
6385
+ from: this.did,
6386
+ to: recipientDid,
6387
+ ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
6388
+ nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
6389
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6390
+ message_type: options.messageType || "text",
6391
+ thread_id: options.threadId
6392
+ });
6393
+ const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelope), this.signingKeyPair.secretKey);
6394
+ const directRes = await this._timedFetch(webhookUrl, {
6395
+ method: "POST",
6396
+ headers: {
6397
+ "Content-Type": "application/json",
6398
+ "X-Voidly-Signature": `sha256=${(0, import_tweetnacl_util.encodeBase64)(signature)}`,
6399
+ "X-Voidly-Sender": this.did
6400
+ },
6401
+ body: envelope
6402
+ });
6403
+ if (directRes.ok) {
6404
+ const now = /* @__PURE__ */ new Date();
6405
+ return {
6406
+ id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
6407
+ from: this.did,
6408
+ to: recipientDid,
6409
+ timestamp: now.toISOString(),
6410
+ expiresAt: new Date(now.getTime() + 864e5).toISOString(),
6411
+ encrypted: true,
6412
+ clientSide: true,
6413
+ direct: true
6414
+ };
6415
+ }
6416
+ }
6417
+ }
6418
+ }
6419
+ } catch {
6420
+ }
6421
+ const result = await this.send(recipientDid, message, options);
6422
+ return { ...result, direct: false };
6423
+ }
6424
+ // ═══════════════════════════════════════════════════════════════════════════
6425
+ // COVER TRAFFIC — Noise Protocol for Traffic Analysis Resistance
6426
+ // ═══════════════════════════════════════════════════════════════════════════
6427
+ /**
6428
+ * Enable cover traffic — sends encrypted noise at random intervals.
6429
+ * Makes real messages indistinguishable from cover traffic for any observer
6430
+ * monitoring message timing and frequency.
6431
+ *
6432
+ * Cover messages are encrypted and padded identically to real messages.
6433
+ * The relay cannot distinguish them from real traffic.
6434
+ *
6435
+ * @example
6436
+ * ```ts
6437
+ * // Send noise every ~30s (randomized ±50%)
6438
+ * agent.enableCoverTraffic({ intervalMs: 30000 });
6439
+ *
6440
+ * // Stop cover traffic
6441
+ * agent.disableCoverTraffic();
6442
+ * ```
6443
+ */
6444
+ enableCoverTraffic(options = {}) {
6445
+ this.disableCoverTraffic();
6446
+ const baseInterval = options.intervalMs || 3e4;
6447
+ const sendNoise = async () => {
6448
+ try {
6449
+ const noise = import_tweetnacl.default.randomBytes(128 + Math.floor(Math.random() * 384));
6450
+ await this.send(this.did, (0, import_tweetnacl_util.encodeBase64)(noise), {
6451
+ messageType: "ping",
6452
+ // use 'ping' type — indistinguishable in encrypted payload
6453
+ ttl: 60
6454
+ // short TTL — noise auto-expires
6455
+ });
6456
+ } catch {
6457
+ }
6458
+ };
6459
+ const scheduleNext = () => {
6460
+ const jitter = baseInterval * (0.5 + Math.random());
6461
+ this._coverTrafficTimer = setTimeout(async () => {
6462
+ await sendNoise();
6463
+ if (this._coverTrafficTimer !== null) scheduleNext();
6464
+ }, jitter);
6465
+ };
6466
+ scheduleNext();
6467
+ }
6468
+ /**
6469
+ * Disable cover traffic.
6470
+ */
6471
+ disableCoverTraffic() {
6472
+ if (this._coverTrafficTimer !== null) {
6473
+ clearTimeout(this._coverTrafficTimer);
6474
+ this._coverTrafficTimer = null;
6475
+ }
6476
+ }
6477
+ // ═══════════════════════════════════════════════════════════════════════════
6478
+ // RESILIENT OPERATIONS — Fallback for All Operations
6479
+ // ═══════════════════════════════════════════════════════════════════════════
6480
+ /**
6481
+ * Fetch from primary relay with fallback to alternate relays.
6482
+ * Unlike _timedFetch which only hits one URL, this tries all known relays.
6483
+ * @internal
6484
+ */
6485
+ async _resilientFetch(path, init) {
6486
+ const relays = [this.baseUrl, ...this.fallbackRelays];
6487
+ let lastError = null;
6488
+ for (const relay of relays) {
6489
+ try {
6490
+ const res = await this._timedFetch(`${relay}${path}`, init);
6491
+ if (res.ok || res.status >= 400 && res.status < 500) return res;
6492
+ } catch (err) {
6493
+ lastError = err instanceof Error ? err : new Error(String(err));
6494
+ }
6495
+ }
6496
+ throw lastError || new Error("All relays failed");
5768
6497
  }
5769
6498
  // ═══════════════════════════════════════════════════════════════════════════
5770
6499
  // CONVERSATIONS — Thread Management
@@ -5903,52 +6632,68 @@ var VoidlyAgent = class _VoidlyAgent {
5903
6632
  return {
5904
6633
  relayCanSee: [
5905
6634
  "Your DID (public identifier)",
5906
- "Who you message (recipient DIDs)",
5907
- "When you message (timestamps)",
5908
- "Message types (text, task-request, etc.)",
5909
- "Thread structure (which messages are replies)",
5910
- "Channel membership",
6635
+ "Recipient DIDs (relay needs them for routing)",
6636
+ ...this.jitterMs > 0 ? ["Approximate timing (jittered timestamps)"] : ["When you message (timestamps)"],
6637
+ ...this.sealedSender ? [] : ["Message types, thread IDs, content types (sent in cleartext without sealed sender)"],
6638
+ "Channel membership (but NOT channel message content with client-side encryption)",
5911
6639
  "Capability registrations",
5912
- "Online/offline status",
5913
6640
  "Approximate message size (even with padding, bounded to power-of-2)"
5914
6641
  ],
5915
6642
  relayCannotSee: [
5916
- "Message content (E2E encrypted \u2014 nacl.secretbox with ratchet-derived per-message keys)",
6643
+ "Message content (E2E encrypted \u2014 nacl.box with Double Ratchet per-message keys)",
5917
6644
  "Private keys (generated and stored client-side only)",
5918
6645
  "Memory values (encrypted CLIENT-SIDE with nacl.secretbox before relay storage)",
5919
- "Past message keys (hash ratchet provides forward secrecy \u2014 old keys are deleted)",
5920
- ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
6646
+ "Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
6647
+ "Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
6648
+ "Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
6649
+ ...this.sealedSender ? [
6650
+ 'Sender identity (sealed inside ciphertext \u2014 relay stores "sealed" not your DID)',
6651
+ "Message types, thread IDs, reply chains (packed inside ciphertext in v3)",
6652
+ "Message count (not incremented for sealed senders)"
6653
+ ] : [],
6654
+ ...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
5921
6655
  ],
5922
6656
  protections: [
5923
- "Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted",
6657
+ ...this.doubleRatchet ? ["Double Ratchet (Signal Protocol) \u2014 DH ratchet for post-compromise recovery + hash ratchet for forward secrecy"] : ["Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted"],
5924
6658
  ...this.postQuantum && this.mlkemPublicKey ? ["ML-KEM-768 + X25519 hybrid key exchange (NIST FIPS 203 post-quantum, harvest-now-decrypt-later resistant)"] : [],
6659
+ "X3DH async key agreement (signed prekeys + one-time prekeys for offline session establishment)",
5925
6660
  "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
5926
- "Ed25519 signatures on every message (envelope + ciphertext hash)",
6661
+ ...this.deniable ? ["Deniable authentication (HMAC-SHA256 with shared DH secret \u2014 both parties can produce the MAC)"] : ["Ed25519 signatures on every message (envelope + ciphertext hash)"],
5927
6662
  "TOFU key pinning (MitM detection on key change)",
5928
6663
  "Client-side memory encryption (relay never sees plaintext values)",
5929
- "Protocol version header (deterministic padding/sealing detection, no heuristics)",
6664
+ "Client-side channel encryption (nacl.secretbox \u2014 relay never sees channel plaintext)",
6665
+ "Protocol version header (deterministic padding/sealing/ratchet detection, no heuristics)",
5930
6666
  "Identity cache (reduced key lookups, 5-min TTL)",
5931
6667
  "Message deduplication (track seen message IDs)",
5932
6668
  "Request timeouts (AbortController on all HTTP, configurable)",
5933
6669
  "Request validation (fromCredentials validates key sizes and format)",
5934
6670
  ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
5935
- ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
6671
+ ...this.sealedSender ? [
6672
+ "Sealed sender (relay cannot see who sent a message)",
6673
+ "Metadata privacy (v3 \u2014 thread_id, message_type, reply_to packed inside ciphertext, stripped from relay storage)"
6674
+ ] : [],
5936
6675
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
5937
- ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
6676
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
6677
+ ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6678
+ ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
6679
+ ...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
6680
+ "Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
6681
+ "P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
6682
+ "Resilient operations (receive, discover, identity \u2014 all try fallback relays)",
5938
6683
  "Auto-retry with exponential backoff",
5939
6684
  "Offline message queue",
5940
6685
  "did:key interoperability (W3C standard DID format)"
5941
6686
  ],
5942
6687
  gaps: [
5943
- "No DH ratchet yet \u2014 hash ratchet only (no post-compromise recovery, planned for v3)",
5944
6688
  ...!this.postQuantum || !this.mlkemPublicKey ? ["No post-quantum protection \u2014 enable postQuantum option and re-register"] : [],
5945
- "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
5946
- "Metadata (who, when, thread structure) visible to relay operator",
5947
- "Single relay architecture (no onion routing, no mix network)",
5948
- "Ed25519 signatures are non-repudiable (no deniable messaging)",
5949
- "No async key agreement (no X3DH prekeys)",
5950
- "Polling-based (no WebSocket real-time transport)",
5951
- "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)"
6689
+ ...this.sealedSender ? ["Relay sees to_did (needed for routing) but NOT from_did, thread_id, or message_type"] : ["Relay sees from_did, to_did, thread_id, message_type in cleartext \u2014 enable sealedSender to strip metadata"],
6690
+ "Relay sees channel membership, task delegation, trust scores (social graph)",
6691
+ ...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
6692
+ "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6693
+ ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6694
+ ...!this.doubleRatchet ? ["Hash ratchet only \u2014 enable doubleRatchet option for post-compromise recovery"] : [],
6695
+ ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
6696
+ ...this._coverTrafficTimer === null ? ["No cover traffic \u2014 call enableCoverTraffic() to resist traffic analysis"] : []
5952
6697
  ]
5953
6698
  };
5954
6699
  }