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