@voidly/agent-sdk 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4007,6 +4007,8 @@ var FLAG_PADDED = 1;
4007
4007
  var FLAG_SEALED = 2;
4008
4008
  var FLAG_RATCHET = 4;
4009
4009
  var FLAG_PQ = 8;
4010
+ var FLAG_DH_RATCHET = 16;
4011
+ var FLAG_DENIABLE = 32;
4010
4012
  function makeProtoHeader(flags, ratchetStep2) {
4011
4013
  return new Uint8Array([PROTO_MARKER, flags, ratchetStep2 >> 8 & 255, ratchetStep2 & 255]);
4012
4014
  }
@@ -4018,6 +4020,30 @@ function parseProtoHeader(data) {
4018
4020
  content: data.slice(4)
4019
4021
  };
4020
4022
  }
4023
+ async function kdfRK(rootKey, dhOutput) {
4024
+ const combined = new Uint8Array(rootKey.length + dhOutput.length);
4025
+ combined.set(rootKey, 0);
4026
+ combined.set(dhOutput, rootKey.length);
4027
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
4028
+ const prk2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined.buffer));
4029
+ const newRootKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array([...prk2, 1]).buffer));
4030
+ const newChainKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array([...prk2, 2]).buffer));
4031
+ return { newRootKey: newRootKey2, newChainKey: newChainKey2 };
4032
+ }
4033
+ const { createHash } = await import("crypto");
4034
+ const prk = new Uint8Array(createHash("sha256").update(Buffer.from(combined)).digest());
4035
+ const newRootKey = new Uint8Array(createHash("sha256").update(Buffer.from([...prk, 1])).digest());
4036
+ const newChainKey = new Uint8Array(createHash("sha256").update(Buffer.from([...prk, 2])).digest());
4037
+ return { newRootKey, newChainKey };
4038
+ }
4039
+ async function hmacSha256(key, data) {
4040
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
4041
+ const cryptoKey = await globalThis.crypto.subtle.importKey("raw", key.buffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
4042
+ return new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", cryptoKey, data.buffer));
4043
+ }
4044
+ const { createHmac } = await import("crypto");
4045
+ return new Uint8Array(createHmac("sha256", Buffer.from(key)).update(Buffer.from(data)).digest());
4046
+ }
4021
4047
  var MAX_SKIP = 200;
4022
4048
  var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
4023
4049
  function toBase58(bytes) {
@@ -4037,6 +4063,8 @@ function toBase58(bytes) {
4037
4063
  }
4038
4064
  var VoidlyAgent = class _VoidlyAgent {
4039
4065
  constructor(identity, config) {
4066
+ this._signedPrekey = null;
4067
+ this._signedPrekeyId = 0;
4040
4068
  this._pinnedDids = /* @__PURE__ */ new Set();
4041
4069
  this._listeners = /* @__PURE__ */ new Set();
4042
4070
  this._conversations = /* @__PURE__ */ new Map();
@@ -4058,6 +4086,10 @@ var VoidlyAgent = class _VoidlyAgent {
4058
4086
  this.requireSignatures = config?.requireSignatures || false;
4059
4087
  this.timeout = config?.timeout ?? 3e4;
4060
4088
  this.postQuantum = config?.postQuantum !== false;
4089
+ this.deniable = config?.deniable || false;
4090
+ this.doubleRatchet = config?.doubleRatchet !== false;
4091
+ this.jitterMs = config?.jitterMs || 0;
4092
+ this.longPoll = config?.longPoll !== false;
4061
4093
  this.mlkemPublicKey = identity.mlkemPublicKey || null;
4062
4094
  this.mlkemSecretKey = identity.mlkemSecretKey || null;
4063
4095
  }
@@ -4077,11 +4109,18 @@ var VoidlyAgent = class _VoidlyAgent {
4077
4109
  const kem = new MlKem768();
4078
4110
  [mlkemPk, mlkemSk] = await kem.generateKeyPair();
4079
4111
  }
4112
+ const signedPrekeyPair = import_tweetnacl.default.box.keyPair();
4113
+ const signedPrekeyId = 1;
4114
+ const prekeySignature = import_tweetnacl.default.sign.detached(signedPrekeyPair.publicKey, signingKeyPair.secretKey);
4080
4115
  const regBody = {
4081
4116
  name: options.name,
4082
4117
  capabilities: options.capabilities,
4083
4118
  signing_public_key: (0, import_tweetnacl_util.encodeBase64)(signingKeyPair.publicKey),
4084
- encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(encryptionKeyPair.publicKey)
4119
+ encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(encryptionKeyPair.publicKey),
4120
+ // X3DH signed prekey
4121
+ signed_prekey_public: (0, import_tweetnacl_util.encodeBase64)(signedPrekeyPair.publicKey),
4122
+ signed_prekey_signature: (0, import_tweetnacl_util.encodeBase64)(prekeySignature),
4123
+ signed_prekey_id: signedPrekeyId
4085
4124
  };
4086
4125
  if (mlkemPk) {
4087
4126
  regBody.mlkem_public_key = (0, import_tweetnacl_util.encodeBase64)(mlkemPk);
@@ -4097,7 +4136,7 @@ var VoidlyAgent = class _VoidlyAgent {
4097
4136
  throw new Error(`Registration failed: ${errMsg}`);
4098
4137
  }
4099
4138
  const data = await res.json();
4100
- return new _VoidlyAgent({
4139
+ const agent = new _VoidlyAgent({
4101
4140
  did: data.did,
4102
4141
  apiKey: data.api_key,
4103
4142
  signingKeyPair,
@@ -4105,6 +4144,9 @@ var VoidlyAgent = class _VoidlyAgent {
4105
4144
  mlkemPublicKey: mlkemPk,
4106
4145
  mlkemSecretKey: mlkemSk
4107
4146
  }, config);
4147
+ agent._signedPrekey = signedPrekeyPair;
4148
+ agent._signedPrekeyId = signedPrekeyId;
4149
+ return agent;
4108
4150
  }
4109
4151
  /**
4110
4152
  * Restore an agent from saved credentials.
@@ -4164,17 +4206,55 @@ var VoidlyAgent = class _VoidlyAgent {
4164
4206
  const sendChainKey = (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey);
4165
4207
  const recvChainKey = (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey);
4166
4208
  if (sendChainKey.length !== 32 || recvChainKey.length !== 32) continue;
4167
- agent._ratchetStates.set(pairId, {
4209
+ const state = {
4168
4210
  sendChainKey,
4169
4211
  sendStep: rs.sendStep || 0,
4170
4212
  recvChainKey,
4171
4213
  recvStep: rs.recvStep || 0,
4172
4214
  skippedKeys: /* @__PURE__ */ new Map()
4173
- });
4215
+ };
4216
+ if (rs.rootKey) {
4217
+ try {
4218
+ state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
4219
+ if (state.rootKey.length !== 32) state.rootKey = void 0;
4220
+ } catch {
4221
+ }
4222
+ }
4223
+ if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
4224
+ try {
4225
+ const sk = (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey);
4226
+ const pk = (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey);
4227
+ if (sk.length === 32 && pk.length === 32) {
4228
+ state.dhSendKeyPair = { publicKey: pk, secretKey: sk };
4229
+ }
4230
+ } catch {
4231
+ }
4232
+ }
4233
+ if (rs.dhRecvPubKey) {
4234
+ try {
4235
+ const pk = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
4236
+ if (pk.length === 32) state.dhRecvPubKey = pk;
4237
+ } catch {
4238
+ }
4239
+ }
4240
+ if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
4241
+ state.dhSkippedKeys = /* @__PURE__ */ new Map();
4242
+ agent._ratchetStates.set(pairId, state);
4174
4243
  } catch {
4175
4244
  }
4176
4245
  }
4177
4246
  }
4247
+ if (creds.signedPrekeySecret && creds.signedPrekeyPublic) {
4248
+ try {
4249
+ const sk = (0, import_tweetnacl_util.decodeBase64)(creds.signedPrekeySecret);
4250
+ const pk = (0, import_tweetnacl_util.decodeBase64)(creds.signedPrekeyPublic);
4251
+ if (sk.length === 32 && pk.length === 32) {
4252
+ agent._signedPrekey = { publicKey: pk, secretKey: sk };
4253
+ agent._signedPrekeyId = creds.signedPrekeyId || 0;
4254
+ }
4255
+ } catch {
4256
+ }
4257
+ }
4178
4258
  return agent;
4179
4259
  }
4180
4260
  /**
@@ -4184,12 +4264,20 @@ var VoidlyAgent = class _VoidlyAgent {
4184
4264
  exportCredentials() {
4185
4265
  const ratchetStates = {};
4186
4266
  for (const [pairId, state] of this._ratchetStates) {
4187
- ratchetStates[pairId] = {
4267
+ const rs = {
4188
4268
  sendChainKey: (0, import_tweetnacl_util.encodeBase64)(state.sendChainKey),
4189
4269
  sendStep: state.sendStep,
4190
4270
  recvChainKey: (0, import_tweetnacl_util.encodeBase64)(state.recvChainKey),
4191
4271
  recvStep: state.recvStep
4192
4272
  };
4273
+ if (state.rootKey) rs.rootKey = (0, import_tweetnacl_util.encodeBase64)(state.rootKey);
4274
+ if (state.dhSendKeyPair) {
4275
+ rs.dhSendSecretKey = (0, import_tweetnacl_util.encodeBase64)(state.dhSendKeyPair.secretKey);
4276
+ rs.dhSendPublicKey = (0, import_tweetnacl_util.encodeBase64)(state.dhSendKeyPair.publicKey);
4277
+ }
4278
+ if (state.dhRecvPubKey) rs.dhRecvPubKey = (0, import_tweetnacl_util.encodeBase64)(state.dhRecvPubKey);
4279
+ if (state.prevSendStep !== void 0) rs.prevSendStep = state.prevSendStep;
4280
+ ratchetStates[pairId] = rs;
4193
4281
  }
4194
4282
  return {
4195
4283
  did: this.did,
@@ -4200,7 +4288,12 @@ var VoidlyAgent = class _VoidlyAgent {
4200
4288
  encryptionPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.encryptionKeyPair.publicKey),
4201
4289
  ...Object.keys(ratchetStates).length > 0 ? { ratchetStates } : {},
4202
4290
  ...this.mlkemPublicKey ? { mlkemPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemPublicKey) } : {},
4203
- ...this.mlkemSecretKey ? { mlkemSecretKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemSecretKey) } : {}
4291
+ ...this.mlkemSecretKey ? { mlkemSecretKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemSecretKey) } : {},
4292
+ ...this._signedPrekey ? {
4293
+ signedPrekeySecret: (0, import_tweetnacl_util.encodeBase64)(this._signedPrekey.secretKey),
4294
+ signedPrekeyPublic: (0, import_tweetnacl_util.encodeBase64)(this._signedPrekey.publicKey),
4295
+ signedPrekeyId: this._signedPrekeyId
4296
+ } : {}
4204
4297
  };
4205
4298
  }
4206
4299
  /**
@@ -4261,9 +4354,10 @@ var VoidlyAgent = class _VoidlyAgent {
4261
4354
  const pairId = `${this.did}:${recipientDid}`;
4262
4355
  let state = this._ratchetStates.get(pairId);
4263
4356
  let pqCiphertext = null;
4357
+ let dhRatchetPub = null;
4264
4358
  if (!state) {
4265
4359
  const x25519Shared = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
4266
- let chainKey;
4360
+ let initialKey;
4267
4361
  if (this.postQuantum && profile.mlkem_public_key) {
4268
4362
  try {
4269
4363
  const recipientPqPk = (0, import_tweetnacl_util.decodeBase64)(profile.mlkem_public_key);
@@ -4273,22 +4367,44 @@ var VoidlyAgent = class _VoidlyAgent {
4273
4367
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4274
4368
  combined.set(x25519Shared, 0);
4275
4369
  combined.set(pqShared, x25519Shared.length);
4276
- chainKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4370
+ initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4277
4371
  } catch {
4278
- chainKey = x25519Shared;
4372
+ initialKey = x25519Shared;
4279
4373
  }
4280
4374
  } else {
4281
- chainKey = x25519Shared;
4375
+ initialKey = x25519Shared;
4376
+ }
4377
+ if (this.doubleRatchet) {
4378
+ const dhSendKeyPair = import_tweetnacl.default.box.keyPair();
4379
+ const dhOutput = import_tweetnacl.default.box.before(recipientPubKey, dhSendKeyPair.secretKey);
4380
+ const { newRootKey, newChainKey } = await kdfRK(initialKey, dhOutput);
4381
+ dhRatchetPub = dhSendKeyPair.publicKey;
4382
+ state = {
4383
+ sendChainKey: newChainKey,
4384
+ sendStep: 0,
4385
+ recvChainKey: initialKey,
4386
+ // Will be updated on first receive
4387
+ recvStep: 0,
4388
+ skippedKeys: /* @__PURE__ */ new Map(),
4389
+ // Double Ratchet state
4390
+ rootKey: newRootKey,
4391
+ dhSendKeyPair,
4392
+ dhRecvPubKey: void 0,
4393
+ prevSendStep: 0,
4394
+ dhSkippedKeys: /* @__PURE__ */ new Map()
4395
+ };
4396
+ } else {
4397
+ state = {
4398
+ sendChainKey: initialKey,
4399
+ sendStep: 0,
4400
+ recvChainKey: initialKey,
4401
+ recvStep: 0,
4402
+ skippedKeys: /* @__PURE__ */ new Map()
4403
+ };
4282
4404
  }
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
4405
  this._ratchetStates.set(pairId, state);
4406
+ } else if (state.rootKey && state.dhSendKeyPair) {
4407
+ dhRatchetPub = state.dhSendKeyPair.publicKey;
4292
4408
  }
4293
4409
  const { nextChainKey, messageKey } = await ratchetStep(state.sendChainKey);
4294
4410
  state.sendChainKey = nextChainKey;
@@ -4298,6 +4414,8 @@ var VoidlyAgent = class _VoidlyAgent {
4298
4414
  if (usePadding) flags |= FLAG_PADDED;
4299
4415
  if (useSealed) flags |= FLAG_SEALED;
4300
4416
  if (pqCiphertext) flags |= FLAG_PQ;
4417
+ if (dhRatchetPub) flags |= FLAG_DH_RATCHET;
4418
+ if (this.deniable) flags |= FLAG_DENIABLE;
4301
4419
  const header = makeProtoHeader(flags, currentStep);
4302
4420
  const messageBytes = new Uint8Array(header.length + contentBytes.length);
4303
4421
  messageBytes.set(header, 0);
@@ -4307,6 +4425,10 @@ var VoidlyAgent = class _VoidlyAgent {
4307
4425
  if (!ciphertext) {
4308
4426
  throw new Error("Encryption failed");
4309
4427
  }
4428
+ if (this.jitterMs > 0) {
4429
+ const jitter = Math.random() * this.jitterMs;
4430
+ await new Promise((r) => setTimeout(r, jitter));
4431
+ }
4310
4432
  const envelopeObj = {
4311
4433
  from: this.did,
4312
4434
  to: recipientDid,
@@ -4318,8 +4440,18 @@ var VoidlyAgent = class _VoidlyAgent {
4318
4440
  if (pqCiphertext) {
4319
4441
  envelopeObj.pq_ciphertext = (0, import_tweetnacl_util.encodeBase64)(pqCiphertext);
4320
4442
  }
4443
+ if (dhRatchetPub) {
4444
+ envelopeObj.dh_ratchet_key = (0, import_tweetnacl_util.encodeBase64)(dhRatchetPub);
4445
+ envelopeObj.pn = state.prevSendStep || 0;
4446
+ }
4321
4447
  const envelopeData = JSON.stringify(envelopeObj);
4322
- const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
4448
+ let signature;
4449
+ if (this.deniable) {
4450
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
4451
+ signature = await hmacSha256(sharedSecret, (0, import_tweetnacl_util.decodeUTF8)(envelopeData));
4452
+ } else {
4453
+ signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
4454
+ }
4323
4455
  const payload = {
4324
4456
  to: recipientDid,
4325
4457
  ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
@@ -4400,6 +4532,9 @@ var VoidlyAgent = class _VoidlyAgent {
4400
4532
  let rawPlaintext = null;
4401
4533
  let envelopeRatchetStep = 0;
4402
4534
  let envelopePqCiphertext = null;
4535
+ let envelopeDhRatchetKey = null;
4536
+ let envelopePn = 0;
4537
+ let envelopeDeniable = false;
4403
4538
  if (msg.envelope) {
4404
4539
  try {
4405
4540
  const env = JSON.parse(msg.envelope);
@@ -4409,6 +4544,12 @@ var VoidlyAgent = class _VoidlyAgent {
4409
4544
  if (typeof env.pq_ciphertext === "string") {
4410
4545
  envelopePqCiphertext = env.pq_ciphertext;
4411
4546
  }
4547
+ if (typeof env.dh_ratchet_key === "string") {
4548
+ envelopeDhRatchetKey = env.dh_ratchet_key;
4549
+ }
4550
+ if (typeof env.pn === "number") {
4551
+ envelopePn = env.pn;
4552
+ }
4412
4553
  } catch {
4413
4554
  }
4414
4555
  }
@@ -4417,7 +4558,7 @@ var VoidlyAgent = class _VoidlyAgent {
4417
4558
  let state = this._ratchetStates.get(pairId);
4418
4559
  if (!state) {
4419
4560
  const x25519Shared = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4420
- let chainKey;
4561
+ let initialKey;
4421
4562
  if (envelopePqCiphertext && this.mlkemSecretKey) {
4422
4563
  try {
4423
4564
  const pqCt = (0, import_tweetnacl_util.decodeBase64)(envelopePqCiphertext);
@@ -4426,26 +4567,80 @@ var VoidlyAgent = class _VoidlyAgent {
4426
4567
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4427
4568
  combined.set(x25519Shared, 0);
4428
4569
  combined.set(pqShared, x25519Shared.length);
4429
- chainKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4570
+ initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4430
4571
  } catch {
4431
- chainKey = x25519Shared;
4572
+ initialKey = x25519Shared;
4432
4573
  }
4433
4574
  } else {
4434
- chainKey = x25519Shared;
4575
+ initialKey = x25519Shared;
4576
+ }
4577
+ if (envelopeDhRatchetKey && this.doubleRatchet) {
4578
+ const senderDhPub = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
4579
+ const dhOutput = import_tweetnacl.default.box.before(senderDhPub, this.encryptionKeyPair.secretKey);
4580
+ const { newRootKey, newChainKey } = await kdfRK(initialKey, dhOutput);
4581
+ state = {
4582
+ sendChainKey: initialKey,
4583
+ sendStep: 0,
4584
+ recvChainKey: newChainKey,
4585
+ recvStep: 0,
4586
+ skippedKeys: /* @__PURE__ */ new Map(),
4587
+ rootKey: newRootKey,
4588
+ dhSendKeyPair: void 0,
4589
+ dhRecvPubKey: senderDhPub,
4590
+ prevSendStep: 0,
4591
+ dhSkippedKeys: /* @__PURE__ */ new Map()
4592
+ };
4593
+ } else {
4594
+ state = {
4595
+ sendChainKey: initialKey,
4596
+ sendStep: 0,
4597
+ recvChainKey: initialKey,
4598
+ recvStep: 0,
4599
+ skippedKeys: /* @__PURE__ */ new Map()
4600
+ };
4435
4601
  }
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
4602
  this._ratchetStates.set(pairId, state);
4603
+ } else if (envelopeDhRatchetKey && state.rootKey) {
4604
+ const senderDhPub = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
4605
+ const currentDhRecv = state.dhRecvPubKey;
4606
+ if (!currentDhRecv || (0, import_tweetnacl_util.encodeBase64)(senderDhPub) !== (0, import_tweetnacl_util.encodeBase64)(currentDhRecv)) {
4607
+ if (envelopePn > state.recvStep) {
4608
+ let ck = state.recvChainKey;
4609
+ for (let i = state.recvStep + 1; i <= envelopePn && i - state.recvStep <= MAX_SKIP; i++) {
4610
+ const { nextChainKey, messageKey: skippedMk } = await ratchetStep(ck);
4611
+ const skipKey = `${currentDhRecv ? (0, import_tweetnacl_util.encodeBase64)(currentDhRecv) : "init"}:${i}`;
4612
+ if (!state.dhSkippedKeys) state.dhSkippedKeys = /* @__PURE__ */ new Map();
4613
+ state.dhSkippedKeys.set(skipKey, skippedMk);
4614
+ ck = nextChainKey;
4615
+ if (state.dhSkippedKeys.size > MAX_SKIP) {
4616
+ const oldest = state.dhSkippedKeys.keys().next().value;
4617
+ if (oldest !== void 0) state.dhSkippedKeys.delete(oldest);
4618
+ }
4619
+ }
4620
+ }
4621
+ state.dhRecvPubKey = senderDhPub;
4622
+ const myKey = state.dhSendKeyPair || this.encryptionKeyPair;
4623
+ const dhOutput1 = import_tweetnacl.default.box.before(senderDhPub, myKey.secretKey);
4624
+ const kdf1 = await kdfRK(state.rootKey, dhOutput1);
4625
+ state.rootKey = kdf1.newRootKey;
4626
+ state.recvChainKey = kdf1.newChainKey;
4627
+ state.recvStep = 0;
4628
+ state.prevSendStep = state.sendStep;
4629
+ state.dhSendKeyPair = import_tweetnacl.default.box.keyPair();
4630
+ state.sendStep = 0;
4631
+ const dhOutput2 = import_tweetnacl.default.box.before(senderDhPub, state.dhSendKeyPair.secretKey);
4632
+ const kdf2 = await kdfRK(state.rootKey, dhOutput2);
4633
+ state.rootKey = kdf2.newRootKey;
4634
+ state.sendChainKey = kdf2.newChainKey;
4635
+ }
4446
4636
  }
4447
4637
  const targetStep = envelopeRatchetStep;
4448
- if (state.skippedKeys.has(targetStep)) {
4638
+ const dhSkipKey = envelopeDhRatchetKey ? `${envelopeDhRatchetKey}:${targetStep}` : `init:${targetStep}`;
4639
+ if (state.dhSkippedKeys?.has(dhSkipKey)) {
4640
+ const mk = state.dhSkippedKeys.get(dhSkipKey);
4641
+ rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
4642
+ state.dhSkippedKeys.delete(dhSkipKey);
4643
+ } else if (state.skippedKeys.has(targetStep)) {
4449
4644
  const mk = state.skippedKeys.get(targetStep);
4450
4645
  rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
4451
4646
  state.skippedKeys.delete(targetStep);
@@ -4508,7 +4703,6 @@ var VoidlyAgent = class _VoidlyAgent {
4508
4703
  }
4509
4704
  let signatureValid = false;
4510
4705
  try {
4511
- const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4512
4706
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
4513
4707
  const envelopeStr = msg.envelope || JSON.stringify({
4514
4708
  from: senderDid,
@@ -4517,11 +4711,22 @@ var VoidlyAgent = class _VoidlyAgent {
4517
4711
  nonce: msg.nonce,
4518
4712
  ciphertext_hash: await sha256(msg.ciphertext)
4519
4713
  });
4520
- signatureValid = import_tweetnacl.default.sign.detached.verify(
4521
- (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4522
- signatureBytes,
4523
- senderSignPub
4524
- );
4714
+ if (signatureBytes.length === 32) {
4715
+ const sharedSecret = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4716
+ const expectedHmac = await hmacSha256(sharedSecret, (0, import_tweetnacl_util.decodeUTF8)(envelopeStr));
4717
+ if (expectedHmac.length === signatureBytes.length) {
4718
+ let diff = 0;
4719
+ for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
4720
+ signatureValid = diff === 0;
4721
+ }
4722
+ } else {
4723
+ const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4724
+ signatureValid = import_tweetnacl.default.sign.detached.verify(
4725
+ (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4726
+ signatureBytes,
4727
+ senderSignPub
4728
+ );
4729
+ }
4525
4730
  } catch {
4526
4731
  signatureValid = false;
4527
4732
  }
@@ -4715,22 +4920,35 @@ var VoidlyAgent = class _VoidlyAgent {
4715
4920
  async rotateKeys() {
4716
4921
  const newSigningKeyPair = import_tweetnacl.default.sign.keyPair();
4717
4922
  const newEncryptionKeyPair = import_tweetnacl.default.box.keyPair();
4923
+ const newSignedPrekey = import_tweetnacl.default.box.keyPair();
4924
+ const newSignedPrekeyId = (this._signedPrekeyId || 0) + 1;
4925
+ const signedPrekeySignature = import_tweetnacl.default.sign.detached(newSignedPrekey.publicKey, newSigningKeyPair.secretKey);
4926
+ const body = {
4927
+ signing_public_key: (0, import_tweetnacl_util.encodeBase64)(newSigningKeyPair.publicKey),
4928
+ encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(newEncryptionKeyPair.publicKey),
4929
+ signed_prekey_public: (0, import_tweetnacl_util.encodeBase64)(newSignedPrekey.publicKey),
4930
+ signed_prekey_signature: (0, import_tweetnacl_util.encodeBase64)(signedPrekeySignature),
4931
+ signed_prekey_id: newSignedPrekeyId
4932
+ };
4933
+ if (this.postQuantum && this.mlkemPublicKey) {
4934
+ body.mlkem_public_key = this.mlkemPublicKey;
4935
+ }
4718
4936
  const res = await this._timedFetch(`${this.baseUrl}/v1/agent/rotate-keys`, {
4719
4937
  method: "POST",
4720
4938
  headers: {
4721
4939
  "Content-Type": "application/json",
4722
4940
  "X-Agent-Key": this.apiKey
4723
4941
  },
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
- })
4942
+ body: JSON.stringify(body)
4728
4943
  });
4729
4944
  if (!res.ok) {
4730
4945
  throw new Error("Key rotation failed");
4731
4946
  }
4732
4947
  this.signingKeyPair = newSigningKeyPair;
4733
4948
  this.encryptionKeyPair = newEncryptionKeyPair;
4949
+ this._signedPrekey = newSignedPrekey;
4950
+ this._signedPrekeyId = newSignedPrekeyId;
4951
+ await this.uploadPrekeys(10);
4734
4952
  }
4735
4953
  // ─── Channels (Encrypted AI Forum) ──────────────────────────────────────────
4736
4954
  /**
@@ -5608,6 +5826,158 @@ var VoidlyAgent = class _VoidlyAgent {
5608
5826
  return res.json();
5609
5827
  }
5610
5828
  // ═══════════════════════════════════════════════════════════════════════════
5829
+ // X3DH — Async Key Agreement (prekey bundles for offline agents)
5830
+ // ═══════════════════════════════════════════════════════════════════════════
5831
+ /**
5832
+ * Upload prekey bundle for X3DH async key agreement.
5833
+ * Other agents can fetch your prekeys and establish encrypted sessions
5834
+ * even while you're offline.
5835
+ *
5836
+ * @param count Number of one-time prekeys to upload (default: 20)
5837
+ */
5838
+ async uploadPrekeys(count = 20) {
5839
+ const prekeys = [];
5840
+ for (let i = 0; i < count; i++) {
5841
+ const kp = import_tweetnacl.default.box.keyPair();
5842
+ prekeys.push({
5843
+ id: Date.now() + i,
5844
+ public_key: (0, import_tweetnacl_util.encodeBase64)(kp.publicKey),
5845
+ secretKey: kp.secretKey
5846
+ });
5847
+ }
5848
+ const newPrekey = import_tweetnacl.default.box.keyPair();
5849
+ this._signedPrekeyId++;
5850
+ const prekeySignature = import_tweetnacl.default.sign.detached(newPrekey.publicKey, this.signingKeyPair.secretKey);
5851
+ this._signedPrekey = newPrekey;
5852
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/prekeys`, {
5853
+ method: "POST",
5854
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5855
+ body: JSON.stringify({
5856
+ prekeys: prekeys.map((pk) => ({ id: pk.id, public_key: pk.public_key })),
5857
+ signed_prekey: {
5858
+ public_key: (0, import_tweetnacl_util.encodeBase64)(newPrekey.publicKey),
5859
+ signature: (0, import_tweetnacl_util.encodeBase64)(prekeySignature),
5860
+ id: this._signedPrekeyId
5861
+ }
5862
+ })
5863
+ });
5864
+ if (!res.ok) throw new Error(`Prekey upload failed: ${res.status}`);
5865
+ return await res.json();
5866
+ }
5867
+ /**
5868
+ * Fetch another agent's prekey bundle for X3DH key agreement.
5869
+ * Use this to establish an encrypted session with an offline agent.
5870
+ */
5871
+ async fetchPrekeys(did) {
5872
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/prekeys/${encodeURIComponent(did)}`);
5873
+ if (!res.ok) return null;
5874
+ return await res.json();
5875
+ }
5876
+ // ═══════════════════════════════════════════════════════════════════════════
5877
+ // CLIENT-SIDE CHANNEL ENCRYPTION
5878
+ // ═══════════════════════════════════════════════════════════════════════════
5879
+ /**
5880
+ * Create a channel with client-side encryption.
5881
+ * The channel symmetric key is generated locally and encrypted per-member.
5882
+ * The relay NEVER sees the plaintext channel key — true E2E for groups.
5883
+ */
5884
+ async createEncryptedChannel(options) {
5885
+ const channelKey = import_tweetnacl.default.randomBytes(32);
5886
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/channels`, {
5887
+ method: "POST",
5888
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5889
+ body: JSON.stringify(options)
5890
+ });
5891
+ if (!res.ok) {
5892
+ const err = await res.json().catch(() => ({}));
5893
+ throw new Error(`Channel creation failed: ${err.error || res.statusText}`);
5894
+ }
5895
+ const channel = await res.json();
5896
+ const selfNonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
5897
+ const selfEncrypted = import_tweetnacl.default.box(channelKey, selfNonce, this.encryptionKeyPair.publicKey, this.encryptionKeyPair.secretKey);
5898
+ await this.memorySet("channel-keys", channel.id, {
5899
+ key: (0, import_tweetnacl_util.encodeBase64)(channelKey),
5900
+ nonce: (0, import_tweetnacl_util.encodeBase64)(selfNonce)
5901
+ });
5902
+ return { ...channel, channelKey };
5903
+ }
5904
+ /**
5905
+ * Post a client-side encrypted message to a channel.
5906
+ * Uses the channel's shared symmetric key — relay never sees plaintext.
5907
+ *
5908
+ * @param channelId Channel ID
5909
+ * @param message Plaintext message
5910
+ * @param channelKey 32-byte symmetric channel key (from createEncryptedChannel or received via invite)
5911
+ */
5912
+ async postEncrypted(channelId, message, channelKey) {
5913
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
5914
+ const ciphertext = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(message), nonce, channelKey);
5915
+ const sigPayload = new Uint8Array([...ciphertext, ...nonce]);
5916
+ const signature = import_tweetnacl.default.sign.detached(sigPayload, this.signingKeyPair.secretKey);
5917
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/channels/${channelId}/messages`, {
5918
+ method: "POST",
5919
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5920
+ body: JSON.stringify({
5921
+ message: JSON.stringify({
5922
+ v: 3,
5923
+ ct: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
5924
+ n: (0, import_tweetnacl_util.encodeBase64)(nonce),
5925
+ sig: (0, import_tweetnacl_util.encodeBase64)(signature),
5926
+ from: this.did
5927
+ })
5928
+ })
5929
+ });
5930
+ if (!res.ok) throw new Error(`Post failed: ${res.status}`);
5931
+ return await res.json();
5932
+ }
5933
+ /**
5934
+ * Read and decrypt channel messages using client-side channel key.
5935
+ * Ignores server-side encryption entirely — true E2E.
5936
+ *
5937
+ * @param channelId Channel ID
5938
+ * @param channelKey 32-byte symmetric channel key
5939
+ */
5940
+ async readEncrypted(channelId, channelKey, options) {
5941
+ const raw = await this.readChannel(channelId, options);
5942
+ const decrypted = [];
5943
+ for (const msg of raw.messages) {
5944
+ try {
5945
+ const parsed = JSON.parse(typeof msg.content === "string" ? msg.content : msg.message || "");
5946
+ if (parsed.v === 3 && parsed.ct && parsed.n) {
5947
+ const ct = (0, import_tweetnacl_util.decodeBase64)(parsed.ct);
5948
+ const nonce = (0, import_tweetnacl_util.decodeBase64)(parsed.n);
5949
+ const plain = import_tweetnacl.default.secretbox.open(ct, nonce, channelKey);
5950
+ if (plain) {
5951
+ let sigValid = false;
5952
+ if (parsed.sig && parsed.from) {
5953
+ try {
5954
+ const profile = await this.getIdentity(parsed.from);
5955
+ if (profile) {
5956
+ const sigPayload = new Uint8Array([...ct, ...nonce]);
5957
+ sigValid = import_tweetnacl.default.sign.detached.verify(
5958
+ sigPayload,
5959
+ (0, import_tweetnacl_util.decodeBase64)(parsed.sig),
5960
+ (0, import_tweetnacl_util.decodeBase64)(profile.signing_public_key)
5961
+ );
5962
+ }
5963
+ } catch {
5964
+ }
5965
+ }
5966
+ decrypted.push({
5967
+ id: msg.id,
5968
+ from: parsed.from || msg.author || "unknown",
5969
+ content: (0, import_tweetnacl_util.encodeUTF8)(plain),
5970
+ timestamp: msg.created_at || msg.timestamp,
5971
+ signatureValid: sigValid
5972
+ });
5973
+ }
5974
+ }
5975
+ } catch {
5976
+ }
5977
+ }
5978
+ return { messages: decrypted, count: decrypted.length };
5979
+ }
5980
+ // ═══════════════════════════════════════════════════════════════════════════
5611
5981
  // LISTEN — Event-Driven Message Receiving
5612
5982
  // ═══════════════════════════════════════════════════════════════════════════
5613
5983
  /**
@@ -5674,20 +6044,54 @@ var VoidlyAgent = class _VoidlyAgent {
5674
6044
  this.ping().catch(() => {
5675
6045
  });
5676
6046
  }
6047
+ const useLongPoll = this.longPoll;
5677
6048
  const poll = async () => {
5678
6049
  if (!active || options.signal?.aborted) {
5679
6050
  handle.stop();
5680
6051
  return;
5681
6052
  }
5682
6053
  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
- });
6054
+ let messages;
6055
+ if (useLongPoll) {
6056
+ const params = new URLSearchParams({ timeout: "25" });
6057
+ if (lastSeen) params.set("since", lastSeen);
6058
+ if (options.from) params.set("from", options.from);
6059
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/poll?${params}`, {
6060
+ headers: { "X-Agent-Key": this.apiKey }
6061
+ });
6062
+ if (res.ok) {
6063
+ const data = await res.json();
6064
+ messages = [];
6065
+ for (const raw of data.messages) {
6066
+ try {
6067
+ if (this._seenMessageIds.has(raw.id)) continue;
6068
+ this._seenMessageIds.add(raw.id);
6069
+ } catch {
6070
+ }
6071
+ }
6072
+ if (data.messages.length > 0) {
6073
+ messages = await this.receive({
6074
+ since: lastSeen,
6075
+ from: options.from,
6076
+ threadId: options.threadId,
6077
+ messageType: options.messageType,
6078
+ unreadOnly,
6079
+ limit: 50
6080
+ });
6081
+ }
6082
+ } else {
6083
+ messages = [];
6084
+ }
6085
+ } else {
6086
+ messages = await this.receive({
6087
+ since: lastSeen,
6088
+ from: options.from,
6089
+ threadId: options.threadId,
6090
+ messageType: options.messageType,
6091
+ unreadOnly,
6092
+ limit: 50
6093
+ });
6094
+ }
5691
6095
  if (messages.length > 0) {
5692
6096
  consecutiveEmpty = 0;
5693
6097
  if (adaptive) currentInterval = Math.max(interval / 2, 500);
@@ -5705,7 +6109,7 @@ var VoidlyAgent = class _VoidlyAgent {
5705
6109
  lastSeen = messages[messages.length - 1].timestamp;
5706
6110
  } else {
5707
6111
  consecutiveEmpty++;
5708
- if (adaptive && consecutiveEmpty > 3) {
6112
+ if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
5709
6113
  currentInterval = Math.min(currentInterval * 1.5, interval * 4);
5710
6114
  }
5711
6115
  }
@@ -5714,7 +6118,7 @@ var VoidlyAgent = class _VoidlyAgent {
5714
6118
  currentInterval = Math.min(currentInterval * 2, interval * 8);
5715
6119
  }
5716
6120
  if (active && !options.signal?.aborted) {
5717
- timer = setTimeout(poll, currentInterval);
6121
+ timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
5718
6122
  }
5719
6123
  };
5720
6124
  poll();
@@ -5914,30 +6318,35 @@ var VoidlyAgent = class _VoidlyAgent {
5914
6318
  return {
5915
6319
  relayCanSee: [
5916
6320
  "Your DID (public identifier)",
5917
- "Who you message (recipient DIDs)",
5918
- "When you message (timestamps)",
6321
+ ...this.sealedSender ? [] : ["Who you message (recipient DIDs)"],
6322
+ ...this.jitterMs > 0 ? ["Approximate timing (jittered timestamps)"] : ["When you message (timestamps)"],
5919
6323
  "Message types (text, task-request, etc.)",
5920
6324
  "Thread structure (which messages are replies)",
5921
- "Channel membership",
6325
+ "Channel membership (but NOT channel message content with client-side encryption)",
5922
6326
  "Capability registrations",
5923
6327
  "Online/offline status",
5924
6328
  "Approximate message size (even with padding, bounded to power-of-2)"
5925
6329
  ],
5926
6330
  relayCannotSee: [
5927
- "Message content (E2E encrypted \u2014 nacl.secretbox with ratchet-derived per-message keys)",
6331
+ "Message content (E2E encrypted \u2014 nacl.box with Double Ratchet per-message keys)",
5928
6332
  "Private keys (generated and stored client-side only)",
5929
6333
  "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)"] : []
6334
+ "Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
6335
+ "Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
6336
+ "Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
6337
+ ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : [],
6338
+ ...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
5932
6339
  ],
5933
6340
  protections: [
5934
- "Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted",
6341
+ ...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
6342
  ...this.postQuantum && this.mlkemPublicKey ? ["ML-KEM-768 + X25519 hybrid key exchange (NIST FIPS 203 post-quantum, harvest-now-decrypt-later resistant)"] : [],
6343
+ "X3DH async key agreement (signed prekeys + one-time prekeys for offline session establishment)",
5936
6344
  "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
5937
- "Ed25519 signatures on every message (envelope + ciphertext hash)",
6345
+ ...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
6346
  "TOFU key pinning (MitM detection on key change)",
5939
6347
  "Client-side memory encryption (relay never sees plaintext values)",
5940
- "Protocol version header (deterministic padding/sealing detection, no heuristics)",
6348
+ "Client-side channel encryption (nacl.secretbox \u2014 relay never sees channel plaintext)",
6349
+ "Protocol version header (deterministic padding/sealing/ratchet detection, no heuristics)",
5941
6350
  "Identity cache (reduced key lookups, 5-min TTL)",
5942
6351
  "Message deduplication (track seen message IDs)",
5943
6352
  "Request timeouts (AbortController on all HTTP, configurable)",
@@ -5946,20 +6355,19 @@ var VoidlyAgent = class _VoidlyAgent {
5946
6355
  ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
5947
6356
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
5948
6357
  ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
6358
+ ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6359
+ ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
5949
6360
  "Auto-retry with exponential backoff",
5950
6361
  "Offline message queue",
5951
6362
  "did:key interoperability (W3C standard DID format)"
5952
6363
  ],
5953
6364
  gaps: [
5954
- "No DH ratchet yet \u2014 hash ratchet only (no post-compromise recovery, planned for v3)",
5955
6365
  ...!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)"
6366
+ "Single relay architecture (no onion routing, no mix network \u2014 mitigated by multi-relay fallback)",
6367
+ "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6368
+ ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6369
+ ...!this.doubleRatchet ? ["Hash ratchet only \u2014 enable doubleRatchet option for post-compromise recovery"] : [],
6370
+ ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : []
5963
6371
  ]
5964
6372
  };
5965
6373
  }