@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.mjs CHANGED
@@ -3996,6 +3996,8 @@ var FLAG_PADDED = 1;
3996
3996
  var FLAG_SEALED = 2;
3997
3997
  var FLAG_RATCHET = 4;
3998
3998
  var FLAG_PQ = 8;
3999
+ var FLAG_DH_RATCHET = 16;
4000
+ var FLAG_DENIABLE = 32;
3999
4001
  function makeProtoHeader(flags, ratchetStep2) {
4000
4002
  return new Uint8Array([PROTO_MARKER, flags, ratchetStep2 >> 8 & 255, ratchetStep2 & 255]);
4001
4003
  }
@@ -4007,6 +4009,30 @@ function parseProtoHeader(data) {
4007
4009
  content: data.slice(4)
4008
4010
  };
4009
4011
  }
4012
+ async function kdfRK(rootKey, dhOutput) {
4013
+ const combined = new Uint8Array(rootKey.length + dhOutput.length);
4014
+ combined.set(rootKey, 0);
4015
+ combined.set(dhOutput, rootKey.length);
4016
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
4017
+ const prk2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined.buffer));
4018
+ const newRootKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array([...prk2, 1]).buffer));
4019
+ const newChainKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array([...prk2, 2]).buffer));
4020
+ return { newRootKey: newRootKey2, newChainKey: newChainKey2 };
4021
+ }
4022
+ const { createHash } = await import("crypto");
4023
+ const prk = new Uint8Array(createHash("sha256").update(Buffer.from(combined)).digest());
4024
+ const newRootKey = new Uint8Array(createHash("sha256").update(Buffer.from([...prk, 1])).digest());
4025
+ const newChainKey = new Uint8Array(createHash("sha256").update(Buffer.from([...prk, 2])).digest());
4026
+ return { newRootKey, newChainKey };
4027
+ }
4028
+ async function hmacSha256(key, data) {
4029
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
4030
+ const cryptoKey = await globalThis.crypto.subtle.importKey("raw", key.buffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
4031
+ return new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", cryptoKey, data.buffer));
4032
+ }
4033
+ const { createHmac } = await import("crypto");
4034
+ return new Uint8Array(createHmac("sha256", Buffer.from(key)).update(Buffer.from(data)).digest());
4035
+ }
4010
4036
  var MAX_SKIP = 200;
4011
4037
  var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
4012
4038
  function toBase58(bytes) {
@@ -4026,6 +4052,8 @@ function toBase58(bytes) {
4026
4052
  }
4027
4053
  var VoidlyAgent = class _VoidlyAgent {
4028
4054
  constructor(identity, config) {
4055
+ this._signedPrekey = null;
4056
+ this._signedPrekeyId = 0;
4029
4057
  this._pinnedDids = /* @__PURE__ */ new Set();
4030
4058
  this._listeners = /* @__PURE__ */ new Set();
4031
4059
  this._conversations = /* @__PURE__ */ new Map();
@@ -4047,6 +4075,10 @@ var VoidlyAgent = class _VoidlyAgent {
4047
4075
  this.requireSignatures = config?.requireSignatures || false;
4048
4076
  this.timeout = config?.timeout ?? 3e4;
4049
4077
  this.postQuantum = config?.postQuantum !== false;
4078
+ this.deniable = config?.deniable || false;
4079
+ this.doubleRatchet = config?.doubleRatchet !== false;
4080
+ this.jitterMs = config?.jitterMs || 0;
4081
+ this.longPoll = config?.longPoll !== false;
4050
4082
  this.mlkemPublicKey = identity.mlkemPublicKey || null;
4051
4083
  this.mlkemSecretKey = identity.mlkemSecretKey || null;
4052
4084
  }
@@ -4066,11 +4098,18 @@ var VoidlyAgent = class _VoidlyAgent {
4066
4098
  const kem = new MlKem768();
4067
4099
  [mlkemPk, mlkemSk] = await kem.generateKeyPair();
4068
4100
  }
4101
+ const signedPrekeyPair = import_tweetnacl.default.box.keyPair();
4102
+ const signedPrekeyId = 1;
4103
+ const prekeySignature = import_tweetnacl.default.sign.detached(signedPrekeyPair.publicKey, signingKeyPair.secretKey);
4069
4104
  const regBody = {
4070
4105
  name: options.name,
4071
4106
  capabilities: options.capabilities,
4072
4107
  signing_public_key: (0, import_tweetnacl_util.encodeBase64)(signingKeyPair.publicKey),
4073
- encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(encryptionKeyPair.publicKey)
4108
+ encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(encryptionKeyPair.publicKey),
4109
+ // X3DH signed prekey
4110
+ signed_prekey_public: (0, import_tweetnacl_util.encodeBase64)(signedPrekeyPair.publicKey),
4111
+ signed_prekey_signature: (0, import_tweetnacl_util.encodeBase64)(prekeySignature),
4112
+ signed_prekey_id: signedPrekeyId
4074
4113
  };
4075
4114
  if (mlkemPk) {
4076
4115
  regBody.mlkem_public_key = (0, import_tweetnacl_util.encodeBase64)(mlkemPk);
@@ -4086,7 +4125,7 @@ var VoidlyAgent = class _VoidlyAgent {
4086
4125
  throw new Error(`Registration failed: ${errMsg}`);
4087
4126
  }
4088
4127
  const data = await res.json();
4089
- return new _VoidlyAgent({
4128
+ const agent = new _VoidlyAgent({
4090
4129
  did: data.did,
4091
4130
  apiKey: data.api_key,
4092
4131
  signingKeyPair,
@@ -4094,6 +4133,9 @@ var VoidlyAgent = class _VoidlyAgent {
4094
4133
  mlkemPublicKey: mlkemPk,
4095
4134
  mlkemSecretKey: mlkemSk
4096
4135
  }, config);
4136
+ agent._signedPrekey = signedPrekeyPair;
4137
+ agent._signedPrekeyId = signedPrekeyId;
4138
+ return agent;
4097
4139
  }
4098
4140
  /**
4099
4141
  * Restore an agent from saved credentials.
@@ -4153,17 +4195,55 @@ var VoidlyAgent = class _VoidlyAgent {
4153
4195
  const sendChainKey = (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey);
4154
4196
  const recvChainKey = (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey);
4155
4197
  if (sendChainKey.length !== 32 || recvChainKey.length !== 32) continue;
4156
- agent._ratchetStates.set(pairId, {
4198
+ const state = {
4157
4199
  sendChainKey,
4158
4200
  sendStep: rs.sendStep || 0,
4159
4201
  recvChainKey,
4160
4202
  recvStep: rs.recvStep || 0,
4161
4203
  skippedKeys: /* @__PURE__ */ new Map()
4162
- });
4204
+ };
4205
+ if (rs.rootKey) {
4206
+ try {
4207
+ state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
4208
+ if (state.rootKey.length !== 32) state.rootKey = void 0;
4209
+ } catch {
4210
+ }
4211
+ }
4212
+ if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
4213
+ try {
4214
+ const sk = (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey);
4215
+ const pk = (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey);
4216
+ if (sk.length === 32 && pk.length === 32) {
4217
+ state.dhSendKeyPair = { publicKey: pk, secretKey: sk };
4218
+ }
4219
+ } catch {
4220
+ }
4221
+ }
4222
+ if (rs.dhRecvPubKey) {
4223
+ try {
4224
+ const pk = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
4225
+ if (pk.length === 32) state.dhRecvPubKey = pk;
4226
+ } catch {
4227
+ }
4228
+ }
4229
+ if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
4230
+ state.dhSkippedKeys = /* @__PURE__ */ new Map();
4231
+ agent._ratchetStates.set(pairId, state);
4163
4232
  } catch {
4164
4233
  }
4165
4234
  }
4166
4235
  }
4236
+ if (creds.signedPrekeySecret && creds.signedPrekeyPublic) {
4237
+ try {
4238
+ const sk = (0, import_tweetnacl_util.decodeBase64)(creds.signedPrekeySecret);
4239
+ const pk = (0, import_tweetnacl_util.decodeBase64)(creds.signedPrekeyPublic);
4240
+ if (sk.length === 32 && pk.length === 32) {
4241
+ agent._signedPrekey = { publicKey: pk, secretKey: sk };
4242
+ agent._signedPrekeyId = creds.signedPrekeyId || 0;
4243
+ }
4244
+ } catch {
4245
+ }
4246
+ }
4167
4247
  return agent;
4168
4248
  }
4169
4249
  /**
@@ -4173,12 +4253,20 @@ var VoidlyAgent = class _VoidlyAgent {
4173
4253
  exportCredentials() {
4174
4254
  const ratchetStates = {};
4175
4255
  for (const [pairId, state] of this._ratchetStates) {
4176
- ratchetStates[pairId] = {
4256
+ const rs = {
4177
4257
  sendChainKey: (0, import_tweetnacl_util.encodeBase64)(state.sendChainKey),
4178
4258
  sendStep: state.sendStep,
4179
4259
  recvChainKey: (0, import_tweetnacl_util.encodeBase64)(state.recvChainKey),
4180
4260
  recvStep: state.recvStep
4181
4261
  };
4262
+ if (state.rootKey) rs.rootKey = (0, import_tweetnacl_util.encodeBase64)(state.rootKey);
4263
+ if (state.dhSendKeyPair) {
4264
+ rs.dhSendSecretKey = (0, import_tweetnacl_util.encodeBase64)(state.dhSendKeyPair.secretKey);
4265
+ rs.dhSendPublicKey = (0, import_tweetnacl_util.encodeBase64)(state.dhSendKeyPair.publicKey);
4266
+ }
4267
+ if (state.dhRecvPubKey) rs.dhRecvPubKey = (0, import_tweetnacl_util.encodeBase64)(state.dhRecvPubKey);
4268
+ if (state.prevSendStep !== void 0) rs.prevSendStep = state.prevSendStep;
4269
+ ratchetStates[pairId] = rs;
4182
4270
  }
4183
4271
  return {
4184
4272
  did: this.did,
@@ -4189,7 +4277,12 @@ var VoidlyAgent = class _VoidlyAgent {
4189
4277
  encryptionPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.encryptionKeyPair.publicKey),
4190
4278
  ...Object.keys(ratchetStates).length > 0 ? { ratchetStates } : {},
4191
4279
  ...this.mlkemPublicKey ? { mlkemPublicKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemPublicKey) } : {},
4192
- ...this.mlkemSecretKey ? { mlkemSecretKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemSecretKey) } : {}
4280
+ ...this.mlkemSecretKey ? { mlkemSecretKey: (0, import_tweetnacl_util.encodeBase64)(this.mlkemSecretKey) } : {},
4281
+ ...this._signedPrekey ? {
4282
+ signedPrekeySecret: (0, import_tweetnacl_util.encodeBase64)(this._signedPrekey.secretKey),
4283
+ signedPrekeyPublic: (0, import_tweetnacl_util.encodeBase64)(this._signedPrekey.publicKey),
4284
+ signedPrekeyId: this._signedPrekeyId
4285
+ } : {}
4193
4286
  };
4194
4287
  }
4195
4288
  /**
@@ -4250,9 +4343,10 @@ var VoidlyAgent = class _VoidlyAgent {
4250
4343
  const pairId = `${this.did}:${recipientDid}`;
4251
4344
  let state = this._ratchetStates.get(pairId);
4252
4345
  let pqCiphertext = null;
4346
+ let dhRatchetPub = null;
4253
4347
  if (!state) {
4254
4348
  const x25519Shared = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
4255
- let chainKey;
4349
+ let initialKey;
4256
4350
  if (this.postQuantum && profile.mlkem_public_key) {
4257
4351
  try {
4258
4352
  const recipientPqPk = (0, import_tweetnacl_util.decodeBase64)(profile.mlkem_public_key);
@@ -4262,22 +4356,44 @@ var VoidlyAgent = class _VoidlyAgent {
4262
4356
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4263
4357
  combined.set(x25519Shared, 0);
4264
4358
  combined.set(pqShared, x25519Shared.length);
4265
- chainKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4359
+ initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4266
4360
  } catch {
4267
- chainKey = x25519Shared;
4361
+ initialKey = x25519Shared;
4268
4362
  }
4269
4363
  } else {
4270
- chainKey = x25519Shared;
4364
+ initialKey = x25519Shared;
4365
+ }
4366
+ if (this.doubleRatchet) {
4367
+ const dhSendKeyPair = import_tweetnacl.default.box.keyPair();
4368
+ const dhOutput = import_tweetnacl.default.box.before(recipientPubKey, dhSendKeyPair.secretKey);
4369
+ const { newRootKey, newChainKey } = await kdfRK(initialKey, dhOutput);
4370
+ dhRatchetPub = dhSendKeyPair.publicKey;
4371
+ state = {
4372
+ sendChainKey: newChainKey,
4373
+ sendStep: 0,
4374
+ recvChainKey: initialKey,
4375
+ // Will be updated on first receive
4376
+ recvStep: 0,
4377
+ skippedKeys: /* @__PURE__ */ new Map(),
4378
+ // Double Ratchet state
4379
+ rootKey: newRootKey,
4380
+ dhSendKeyPair,
4381
+ dhRecvPubKey: void 0,
4382
+ prevSendStep: 0,
4383
+ dhSkippedKeys: /* @__PURE__ */ new Map()
4384
+ };
4385
+ } else {
4386
+ state = {
4387
+ sendChainKey: initialKey,
4388
+ sendStep: 0,
4389
+ recvChainKey: initialKey,
4390
+ recvStep: 0,
4391
+ skippedKeys: /* @__PURE__ */ new Map()
4392
+ };
4271
4393
  }
4272
- state = {
4273
- sendChainKey: chainKey,
4274
- sendStep: 0,
4275
- recvChainKey: chainKey,
4276
- // Will be synced on first receive
4277
- recvStep: 0,
4278
- skippedKeys: /* @__PURE__ */ new Map()
4279
- };
4280
4394
  this._ratchetStates.set(pairId, state);
4395
+ } else if (state.rootKey && state.dhSendKeyPair) {
4396
+ dhRatchetPub = state.dhSendKeyPair.publicKey;
4281
4397
  }
4282
4398
  const { nextChainKey, messageKey } = await ratchetStep(state.sendChainKey);
4283
4399
  state.sendChainKey = nextChainKey;
@@ -4287,6 +4403,8 @@ var VoidlyAgent = class _VoidlyAgent {
4287
4403
  if (usePadding) flags |= FLAG_PADDED;
4288
4404
  if (useSealed) flags |= FLAG_SEALED;
4289
4405
  if (pqCiphertext) flags |= FLAG_PQ;
4406
+ if (dhRatchetPub) flags |= FLAG_DH_RATCHET;
4407
+ if (this.deniable) flags |= FLAG_DENIABLE;
4290
4408
  const header = makeProtoHeader(flags, currentStep);
4291
4409
  const messageBytes = new Uint8Array(header.length + contentBytes.length);
4292
4410
  messageBytes.set(header, 0);
@@ -4296,6 +4414,10 @@ var VoidlyAgent = class _VoidlyAgent {
4296
4414
  if (!ciphertext) {
4297
4415
  throw new Error("Encryption failed");
4298
4416
  }
4417
+ if (this.jitterMs > 0) {
4418
+ const jitter = Math.random() * this.jitterMs;
4419
+ await new Promise((r) => setTimeout(r, jitter));
4420
+ }
4299
4421
  const envelopeObj = {
4300
4422
  from: this.did,
4301
4423
  to: recipientDid,
@@ -4307,8 +4429,18 @@ var VoidlyAgent = class _VoidlyAgent {
4307
4429
  if (pqCiphertext) {
4308
4430
  envelopeObj.pq_ciphertext = (0, import_tweetnacl_util.encodeBase64)(pqCiphertext);
4309
4431
  }
4432
+ if (dhRatchetPub) {
4433
+ envelopeObj.dh_ratchet_key = (0, import_tweetnacl_util.encodeBase64)(dhRatchetPub);
4434
+ envelopeObj.pn = state.prevSendStep || 0;
4435
+ }
4310
4436
  const envelopeData = JSON.stringify(envelopeObj);
4311
- const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
4437
+ let signature;
4438
+ if (this.deniable) {
4439
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
4440
+ signature = await hmacSha256(sharedSecret, (0, import_tweetnacl_util.decodeUTF8)(envelopeData));
4441
+ } else {
4442
+ signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
4443
+ }
4312
4444
  const payload = {
4313
4445
  to: recipientDid,
4314
4446
  ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
@@ -4389,6 +4521,9 @@ var VoidlyAgent = class _VoidlyAgent {
4389
4521
  let rawPlaintext = null;
4390
4522
  let envelopeRatchetStep = 0;
4391
4523
  let envelopePqCiphertext = null;
4524
+ let envelopeDhRatchetKey = null;
4525
+ let envelopePn = 0;
4526
+ let envelopeDeniable = false;
4392
4527
  if (msg.envelope) {
4393
4528
  try {
4394
4529
  const env = JSON.parse(msg.envelope);
@@ -4398,6 +4533,12 @@ var VoidlyAgent = class _VoidlyAgent {
4398
4533
  if (typeof env.pq_ciphertext === "string") {
4399
4534
  envelopePqCiphertext = env.pq_ciphertext;
4400
4535
  }
4536
+ if (typeof env.dh_ratchet_key === "string") {
4537
+ envelopeDhRatchetKey = env.dh_ratchet_key;
4538
+ }
4539
+ if (typeof env.pn === "number") {
4540
+ envelopePn = env.pn;
4541
+ }
4401
4542
  } catch {
4402
4543
  }
4403
4544
  }
@@ -4406,7 +4547,7 @@ var VoidlyAgent = class _VoidlyAgent {
4406
4547
  let state = this._ratchetStates.get(pairId);
4407
4548
  if (!state) {
4408
4549
  const x25519Shared = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4409
- let chainKey;
4550
+ let initialKey;
4410
4551
  if (envelopePqCiphertext && this.mlkemSecretKey) {
4411
4552
  try {
4412
4553
  const pqCt = (0, import_tweetnacl_util.decodeBase64)(envelopePqCiphertext);
@@ -4415,26 +4556,80 @@ var VoidlyAgent = class _VoidlyAgent {
4415
4556
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4416
4557
  combined.set(x25519Shared, 0);
4417
4558
  combined.set(pqShared, x25519Shared.length);
4418
- chainKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4559
+ initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4419
4560
  } catch {
4420
- chainKey = x25519Shared;
4561
+ initialKey = x25519Shared;
4421
4562
  }
4422
4563
  } else {
4423
- chainKey = x25519Shared;
4564
+ initialKey = x25519Shared;
4565
+ }
4566
+ if (envelopeDhRatchetKey && this.doubleRatchet) {
4567
+ const senderDhPub = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
4568
+ const dhOutput = import_tweetnacl.default.box.before(senderDhPub, this.encryptionKeyPair.secretKey);
4569
+ const { newRootKey, newChainKey } = await kdfRK(initialKey, dhOutput);
4570
+ state = {
4571
+ sendChainKey: initialKey,
4572
+ sendStep: 0,
4573
+ recvChainKey: newChainKey,
4574
+ recvStep: 0,
4575
+ skippedKeys: /* @__PURE__ */ new Map(),
4576
+ rootKey: newRootKey,
4577
+ dhSendKeyPair: void 0,
4578
+ dhRecvPubKey: senderDhPub,
4579
+ prevSendStep: 0,
4580
+ dhSkippedKeys: /* @__PURE__ */ new Map()
4581
+ };
4582
+ } else {
4583
+ state = {
4584
+ sendChainKey: initialKey,
4585
+ sendStep: 0,
4586
+ recvChainKey: initialKey,
4587
+ recvStep: 0,
4588
+ skippedKeys: /* @__PURE__ */ new Map()
4589
+ };
4424
4590
  }
4425
- state = {
4426
- sendChainKey: chainKey,
4427
- // Our sending chain to this peer
4428
- sendStep: 0,
4429
- recvChainKey: chainKey,
4430
- // Their sending chain (our receiving)
4431
- recvStep: 0,
4432
- skippedKeys: /* @__PURE__ */ new Map()
4433
- };
4434
4591
  this._ratchetStates.set(pairId, state);
4592
+ } else if (envelopeDhRatchetKey && state.rootKey) {
4593
+ const senderDhPub = (0, import_tweetnacl_util.decodeBase64)(envelopeDhRatchetKey);
4594
+ const currentDhRecv = state.dhRecvPubKey;
4595
+ if (!currentDhRecv || (0, import_tweetnacl_util.encodeBase64)(senderDhPub) !== (0, import_tweetnacl_util.encodeBase64)(currentDhRecv)) {
4596
+ if (envelopePn > state.recvStep) {
4597
+ let ck = state.recvChainKey;
4598
+ for (let i = state.recvStep + 1; i <= envelopePn && i - state.recvStep <= MAX_SKIP; i++) {
4599
+ const { nextChainKey, messageKey: skippedMk } = await ratchetStep(ck);
4600
+ const skipKey = `${currentDhRecv ? (0, import_tweetnacl_util.encodeBase64)(currentDhRecv) : "init"}:${i}`;
4601
+ if (!state.dhSkippedKeys) state.dhSkippedKeys = /* @__PURE__ */ new Map();
4602
+ state.dhSkippedKeys.set(skipKey, skippedMk);
4603
+ ck = nextChainKey;
4604
+ if (state.dhSkippedKeys.size > MAX_SKIP) {
4605
+ const oldest = state.dhSkippedKeys.keys().next().value;
4606
+ if (oldest !== void 0) state.dhSkippedKeys.delete(oldest);
4607
+ }
4608
+ }
4609
+ }
4610
+ state.dhRecvPubKey = senderDhPub;
4611
+ const myKey = state.dhSendKeyPair || this.encryptionKeyPair;
4612
+ const dhOutput1 = import_tweetnacl.default.box.before(senderDhPub, myKey.secretKey);
4613
+ const kdf1 = await kdfRK(state.rootKey, dhOutput1);
4614
+ state.rootKey = kdf1.newRootKey;
4615
+ state.recvChainKey = kdf1.newChainKey;
4616
+ state.recvStep = 0;
4617
+ state.prevSendStep = state.sendStep;
4618
+ state.dhSendKeyPair = import_tweetnacl.default.box.keyPair();
4619
+ state.sendStep = 0;
4620
+ const dhOutput2 = import_tweetnacl.default.box.before(senderDhPub, state.dhSendKeyPair.secretKey);
4621
+ const kdf2 = await kdfRK(state.rootKey, dhOutput2);
4622
+ state.rootKey = kdf2.newRootKey;
4623
+ state.sendChainKey = kdf2.newChainKey;
4624
+ }
4435
4625
  }
4436
4626
  const targetStep = envelopeRatchetStep;
4437
- if (state.skippedKeys.has(targetStep)) {
4627
+ const dhSkipKey = envelopeDhRatchetKey ? `${envelopeDhRatchetKey}:${targetStep}` : `init:${targetStep}`;
4628
+ if (state.dhSkippedKeys?.has(dhSkipKey)) {
4629
+ const mk = state.dhSkippedKeys.get(dhSkipKey);
4630
+ rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
4631
+ state.dhSkippedKeys.delete(dhSkipKey);
4632
+ } else if (state.skippedKeys.has(targetStep)) {
4438
4633
  const mk = state.skippedKeys.get(targetStep);
4439
4634
  rawPlaintext = import_tweetnacl.default.secretbox.open(ciphertext, nonce, mk);
4440
4635
  state.skippedKeys.delete(targetStep);
@@ -4497,7 +4692,6 @@ var VoidlyAgent = class _VoidlyAgent {
4497
4692
  }
4498
4693
  let signatureValid = false;
4499
4694
  try {
4500
- const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4501
4695
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
4502
4696
  const envelopeStr = msg.envelope || JSON.stringify({
4503
4697
  from: senderDid,
@@ -4506,11 +4700,22 @@ var VoidlyAgent = class _VoidlyAgent {
4506
4700
  nonce: msg.nonce,
4507
4701
  ciphertext_hash: await sha256(msg.ciphertext)
4508
4702
  });
4509
- signatureValid = import_tweetnacl.default.sign.detached.verify(
4510
- (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4511
- signatureBytes,
4512
- senderSignPub
4513
- );
4703
+ if (signatureBytes.length === 32) {
4704
+ const sharedSecret = import_tweetnacl.default.box.before(senderEncPub, this.encryptionKeyPair.secretKey);
4705
+ const expectedHmac = await hmacSha256(sharedSecret, (0, import_tweetnacl_util.decodeUTF8)(envelopeStr));
4706
+ if (expectedHmac.length === signatureBytes.length) {
4707
+ let diff = 0;
4708
+ for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
4709
+ signatureValid = diff === 0;
4710
+ }
4711
+ } else {
4712
+ const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4713
+ signatureValid = import_tweetnacl.default.sign.detached.verify(
4714
+ (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4715
+ signatureBytes,
4716
+ senderSignPub
4717
+ );
4718
+ }
4514
4719
  } catch {
4515
4720
  signatureValid = false;
4516
4721
  }
@@ -4704,22 +4909,35 @@ var VoidlyAgent = class _VoidlyAgent {
4704
4909
  async rotateKeys() {
4705
4910
  const newSigningKeyPair = import_tweetnacl.default.sign.keyPair();
4706
4911
  const newEncryptionKeyPair = import_tweetnacl.default.box.keyPair();
4912
+ const newSignedPrekey = import_tweetnacl.default.box.keyPair();
4913
+ const newSignedPrekeyId = (this._signedPrekeyId || 0) + 1;
4914
+ const signedPrekeySignature = import_tweetnacl.default.sign.detached(newSignedPrekey.publicKey, newSigningKeyPair.secretKey);
4915
+ const body = {
4916
+ signing_public_key: (0, import_tweetnacl_util.encodeBase64)(newSigningKeyPair.publicKey),
4917
+ encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(newEncryptionKeyPair.publicKey),
4918
+ signed_prekey_public: (0, import_tweetnacl_util.encodeBase64)(newSignedPrekey.publicKey),
4919
+ signed_prekey_signature: (0, import_tweetnacl_util.encodeBase64)(signedPrekeySignature),
4920
+ signed_prekey_id: newSignedPrekeyId
4921
+ };
4922
+ if (this.postQuantum && this.mlkemPublicKey) {
4923
+ body.mlkem_public_key = this.mlkemPublicKey;
4924
+ }
4707
4925
  const res = await this._timedFetch(`${this.baseUrl}/v1/agent/rotate-keys`, {
4708
4926
  method: "POST",
4709
4927
  headers: {
4710
4928
  "Content-Type": "application/json",
4711
4929
  "X-Agent-Key": this.apiKey
4712
4930
  },
4713
- body: JSON.stringify({
4714
- signing_public_key: (0, import_tweetnacl_util.encodeBase64)(newSigningKeyPair.publicKey),
4715
- encryption_public_key: (0, import_tweetnacl_util.encodeBase64)(newEncryptionKeyPair.publicKey)
4716
- })
4931
+ body: JSON.stringify(body)
4717
4932
  });
4718
4933
  if (!res.ok) {
4719
4934
  throw new Error("Key rotation failed");
4720
4935
  }
4721
4936
  this.signingKeyPair = newSigningKeyPair;
4722
4937
  this.encryptionKeyPair = newEncryptionKeyPair;
4938
+ this._signedPrekey = newSignedPrekey;
4939
+ this._signedPrekeyId = newSignedPrekeyId;
4940
+ await this.uploadPrekeys(10);
4723
4941
  }
4724
4942
  // ─── Channels (Encrypted AI Forum) ──────────────────────────────────────────
4725
4943
  /**
@@ -5597,6 +5815,158 @@ var VoidlyAgent = class _VoidlyAgent {
5597
5815
  return res.json();
5598
5816
  }
5599
5817
  // ═══════════════════════════════════════════════════════════════════════════
5818
+ // X3DH — Async Key Agreement (prekey bundles for offline agents)
5819
+ // ═══════════════════════════════════════════════════════════════════════════
5820
+ /**
5821
+ * Upload prekey bundle for X3DH async key agreement.
5822
+ * Other agents can fetch your prekeys and establish encrypted sessions
5823
+ * even while you're offline.
5824
+ *
5825
+ * @param count Number of one-time prekeys to upload (default: 20)
5826
+ */
5827
+ async uploadPrekeys(count = 20) {
5828
+ const prekeys = [];
5829
+ for (let i = 0; i < count; i++) {
5830
+ const kp = import_tweetnacl.default.box.keyPair();
5831
+ prekeys.push({
5832
+ id: Date.now() + i,
5833
+ public_key: (0, import_tweetnacl_util.encodeBase64)(kp.publicKey),
5834
+ secretKey: kp.secretKey
5835
+ });
5836
+ }
5837
+ const newPrekey = import_tweetnacl.default.box.keyPair();
5838
+ this._signedPrekeyId++;
5839
+ const prekeySignature = import_tweetnacl.default.sign.detached(newPrekey.publicKey, this.signingKeyPair.secretKey);
5840
+ this._signedPrekey = newPrekey;
5841
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/prekeys`, {
5842
+ method: "POST",
5843
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5844
+ body: JSON.stringify({
5845
+ prekeys: prekeys.map((pk) => ({ id: pk.id, public_key: pk.public_key })),
5846
+ signed_prekey: {
5847
+ public_key: (0, import_tweetnacl_util.encodeBase64)(newPrekey.publicKey),
5848
+ signature: (0, import_tweetnacl_util.encodeBase64)(prekeySignature),
5849
+ id: this._signedPrekeyId
5850
+ }
5851
+ })
5852
+ });
5853
+ if (!res.ok) throw new Error(`Prekey upload failed: ${res.status}`);
5854
+ return await res.json();
5855
+ }
5856
+ /**
5857
+ * Fetch another agent's prekey bundle for X3DH key agreement.
5858
+ * Use this to establish an encrypted session with an offline agent.
5859
+ */
5860
+ async fetchPrekeys(did) {
5861
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/prekeys/${encodeURIComponent(did)}`);
5862
+ if (!res.ok) return null;
5863
+ return await res.json();
5864
+ }
5865
+ // ═══════════════════════════════════════════════════════════════════════════
5866
+ // CLIENT-SIDE CHANNEL ENCRYPTION
5867
+ // ═══════════════════════════════════════════════════════════════════════════
5868
+ /**
5869
+ * Create a channel with client-side encryption.
5870
+ * The channel symmetric key is generated locally and encrypted per-member.
5871
+ * The relay NEVER sees the plaintext channel key — true E2E for groups.
5872
+ */
5873
+ async createEncryptedChannel(options) {
5874
+ const channelKey = import_tweetnacl.default.randomBytes(32);
5875
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/channels`, {
5876
+ method: "POST",
5877
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5878
+ body: JSON.stringify(options)
5879
+ });
5880
+ if (!res.ok) {
5881
+ const err = await res.json().catch(() => ({}));
5882
+ throw new Error(`Channel creation failed: ${err.error || res.statusText}`);
5883
+ }
5884
+ const channel = await res.json();
5885
+ const selfNonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
5886
+ const selfEncrypted = import_tweetnacl.default.box(channelKey, selfNonce, this.encryptionKeyPair.publicKey, this.encryptionKeyPair.secretKey);
5887
+ await this.memorySet("channel-keys", channel.id, {
5888
+ key: (0, import_tweetnacl_util.encodeBase64)(channelKey),
5889
+ nonce: (0, import_tweetnacl_util.encodeBase64)(selfNonce)
5890
+ });
5891
+ return { ...channel, channelKey };
5892
+ }
5893
+ /**
5894
+ * Post a client-side encrypted message to a channel.
5895
+ * Uses the channel's shared symmetric key — relay never sees plaintext.
5896
+ *
5897
+ * @param channelId Channel ID
5898
+ * @param message Plaintext message
5899
+ * @param channelKey 32-byte symmetric channel key (from createEncryptedChannel or received via invite)
5900
+ */
5901
+ async postEncrypted(channelId, message, channelKey) {
5902
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength);
5903
+ const ciphertext = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(message), nonce, channelKey);
5904
+ const sigPayload = new Uint8Array([...ciphertext, ...nonce]);
5905
+ const signature = import_tweetnacl.default.sign.detached(sigPayload, this.signingKeyPair.secretKey);
5906
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/channels/${channelId}/messages`, {
5907
+ method: "POST",
5908
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
5909
+ body: JSON.stringify({
5910
+ message: JSON.stringify({
5911
+ v: 3,
5912
+ ct: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
5913
+ n: (0, import_tweetnacl_util.encodeBase64)(nonce),
5914
+ sig: (0, import_tweetnacl_util.encodeBase64)(signature),
5915
+ from: this.did
5916
+ })
5917
+ })
5918
+ });
5919
+ if (!res.ok) throw new Error(`Post failed: ${res.status}`);
5920
+ return await res.json();
5921
+ }
5922
+ /**
5923
+ * Read and decrypt channel messages using client-side channel key.
5924
+ * Ignores server-side encryption entirely — true E2E.
5925
+ *
5926
+ * @param channelId Channel ID
5927
+ * @param channelKey 32-byte symmetric channel key
5928
+ */
5929
+ async readEncrypted(channelId, channelKey, options) {
5930
+ const raw = await this.readChannel(channelId, options);
5931
+ const decrypted = [];
5932
+ for (const msg of raw.messages) {
5933
+ try {
5934
+ const parsed = JSON.parse(typeof msg.content === "string" ? msg.content : msg.message || "");
5935
+ if (parsed.v === 3 && parsed.ct && parsed.n) {
5936
+ const ct = (0, import_tweetnacl_util.decodeBase64)(parsed.ct);
5937
+ const nonce = (0, import_tweetnacl_util.decodeBase64)(parsed.n);
5938
+ const plain = import_tweetnacl.default.secretbox.open(ct, nonce, channelKey);
5939
+ if (plain) {
5940
+ let sigValid = false;
5941
+ if (parsed.sig && parsed.from) {
5942
+ try {
5943
+ const profile = await this.getIdentity(parsed.from);
5944
+ if (profile) {
5945
+ const sigPayload = new Uint8Array([...ct, ...nonce]);
5946
+ sigValid = import_tweetnacl.default.sign.detached.verify(
5947
+ sigPayload,
5948
+ (0, import_tweetnacl_util.decodeBase64)(parsed.sig),
5949
+ (0, import_tweetnacl_util.decodeBase64)(profile.signing_public_key)
5950
+ );
5951
+ }
5952
+ } catch {
5953
+ }
5954
+ }
5955
+ decrypted.push({
5956
+ id: msg.id,
5957
+ from: parsed.from || msg.author || "unknown",
5958
+ content: (0, import_tweetnacl_util.encodeUTF8)(plain),
5959
+ timestamp: msg.created_at || msg.timestamp,
5960
+ signatureValid: sigValid
5961
+ });
5962
+ }
5963
+ }
5964
+ } catch {
5965
+ }
5966
+ }
5967
+ return { messages: decrypted, count: decrypted.length };
5968
+ }
5969
+ // ═══════════════════════════════════════════════════════════════════════════
5600
5970
  // LISTEN — Event-Driven Message Receiving
5601
5971
  // ═══════════════════════════════════════════════════════════════════════════
5602
5972
  /**
@@ -5663,20 +6033,54 @@ var VoidlyAgent = class _VoidlyAgent {
5663
6033
  this.ping().catch(() => {
5664
6034
  });
5665
6035
  }
6036
+ const useLongPoll = this.longPoll;
5666
6037
  const poll = async () => {
5667
6038
  if (!active || options.signal?.aborted) {
5668
6039
  handle.stop();
5669
6040
  return;
5670
6041
  }
5671
6042
  try {
5672
- const messages = await this.receive({
5673
- since: lastSeen,
5674
- from: options.from,
5675
- threadId: options.threadId,
5676
- messageType: options.messageType,
5677
- unreadOnly,
5678
- limit: 50
5679
- });
6043
+ let messages;
6044
+ if (useLongPoll) {
6045
+ const params = new URLSearchParams({ timeout: "25" });
6046
+ if (lastSeen) params.set("since", lastSeen);
6047
+ if (options.from) params.set("from", options.from);
6048
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/poll?${params}`, {
6049
+ headers: { "X-Agent-Key": this.apiKey }
6050
+ });
6051
+ if (res.ok) {
6052
+ const data = await res.json();
6053
+ messages = [];
6054
+ for (const raw of data.messages) {
6055
+ try {
6056
+ if (this._seenMessageIds.has(raw.id)) continue;
6057
+ this._seenMessageIds.add(raw.id);
6058
+ } catch {
6059
+ }
6060
+ }
6061
+ if (data.messages.length > 0) {
6062
+ messages = await this.receive({
6063
+ since: lastSeen,
6064
+ from: options.from,
6065
+ threadId: options.threadId,
6066
+ messageType: options.messageType,
6067
+ unreadOnly,
6068
+ limit: 50
6069
+ });
6070
+ }
6071
+ } else {
6072
+ messages = [];
6073
+ }
6074
+ } else {
6075
+ messages = await this.receive({
6076
+ since: lastSeen,
6077
+ from: options.from,
6078
+ threadId: options.threadId,
6079
+ messageType: options.messageType,
6080
+ unreadOnly,
6081
+ limit: 50
6082
+ });
6083
+ }
5680
6084
  if (messages.length > 0) {
5681
6085
  consecutiveEmpty = 0;
5682
6086
  if (adaptive) currentInterval = Math.max(interval / 2, 500);
@@ -5694,7 +6098,7 @@ var VoidlyAgent = class _VoidlyAgent {
5694
6098
  lastSeen = messages[messages.length - 1].timestamp;
5695
6099
  } else {
5696
6100
  consecutiveEmpty++;
5697
- if (adaptive && consecutiveEmpty > 3) {
6101
+ if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
5698
6102
  currentInterval = Math.min(currentInterval * 1.5, interval * 4);
5699
6103
  }
5700
6104
  }
@@ -5703,7 +6107,7 @@ var VoidlyAgent = class _VoidlyAgent {
5703
6107
  currentInterval = Math.min(currentInterval * 2, interval * 8);
5704
6108
  }
5705
6109
  if (active && !options.signal?.aborted) {
5706
- timer = setTimeout(poll, currentInterval);
6110
+ timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
5707
6111
  }
5708
6112
  };
5709
6113
  poll();
@@ -5903,30 +6307,35 @@ var VoidlyAgent = class _VoidlyAgent {
5903
6307
  return {
5904
6308
  relayCanSee: [
5905
6309
  "Your DID (public identifier)",
5906
- "Who you message (recipient DIDs)",
5907
- "When you message (timestamps)",
6310
+ ...this.sealedSender ? [] : ["Who you message (recipient DIDs)"],
6311
+ ...this.jitterMs > 0 ? ["Approximate timing (jittered timestamps)"] : ["When you message (timestamps)"],
5908
6312
  "Message types (text, task-request, etc.)",
5909
6313
  "Thread structure (which messages are replies)",
5910
- "Channel membership",
6314
+ "Channel membership (but NOT channel message content with client-side encryption)",
5911
6315
  "Capability registrations",
5912
6316
  "Online/offline status",
5913
6317
  "Approximate message size (even with padding, bounded to power-of-2)"
5914
6318
  ],
5915
6319
  relayCannotSee: [
5916
- "Message content (E2E encrypted \u2014 nacl.secretbox with ratchet-derived per-message keys)",
6320
+ "Message content (E2E encrypted \u2014 nacl.box with Double Ratchet per-message keys)",
5917
6321
  "Private keys (generated and stored client-side only)",
5918
6322
  "Memory values (encrypted CLIENT-SIDE with nacl.secretbox before relay storage)",
5919
- "Past message keys (hash ratchet provides forward secrecy \u2014 old keys are deleted)",
5920
- ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
6323
+ "Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
6324
+ "Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
6325
+ "Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
6326
+ ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : [],
6327
+ ...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
5921
6328
  ],
5922
6329
  protections: [
5923
- "Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted",
6330
+ ...this.doubleRatchet ? ["Double Ratchet (Signal Protocol) \u2014 DH ratchet for post-compromise recovery + hash ratchet for forward secrecy"] : ["Hash ratchet forward secrecy \u2014 per-message key derivation, old keys deleted"],
5924
6331
  ...this.postQuantum && this.mlkemPublicKey ? ["ML-KEM-768 + X25519 hybrid key exchange (NIST FIPS 203 post-quantum, harvest-now-decrypt-later resistant)"] : [],
6332
+ "X3DH async key agreement (signed prekeys + one-time prekeys for offline session establishment)",
5925
6333
  "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
5926
- "Ed25519 signatures on every message (envelope + ciphertext hash)",
6334
+ ...this.deniable ? ["Deniable authentication (HMAC-SHA256 with shared DH secret \u2014 both parties can produce the MAC)"] : ["Ed25519 signatures on every message (envelope + ciphertext hash)"],
5927
6335
  "TOFU key pinning (MitM detection on key change)",
5928
6336
  "Client-side memory encryption (relay never sees plaintext values)",
5929
- "Protocol version header (deterministic padding/sealing detection, no heuristics)",
6337
+ "Client-side channel encryption (nacl.secretbox \u2014 relay never sees channel plaintext)",
6338
+ "Protocol version header (deterministic padding/sealing/ratchet detection, no heuristics)",
5930
6339
  "Identity cache (reduced key lookups, 5-min TTL)",
5931
6340
  "Message deduplication (track seen message IDs)",
5932
6341
  "Request timeouts (AbortController on all HTTP, configurable)",
@@ -5935,20 +6344,19 @@ var VoidlyAgent = class _VoidlyAgent {
5935
6344
  ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
5936
6345
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
5937
6346
  ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
6347
+ ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6348
+ ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
5938
6349
  "Auto-retry with exponential backoff",
5939
6350
  "Offline message queue",
5940
6351
  "did:key interoperability (W3C standard DID format)"
5941
6352
  ],
5942
6353
  gaps: [
5943
- "No DH ratchet yet \u2014 hash ratchet only (no post-compromise recovery, planned for v3)",
5944
6354
  ...!this.postQuantum || !this.mlkemPublicKey ? ["No post-quantum protection \u2014 enable postQuantum option and re-register"] : [],
5945
- "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
5946
- "Metadata (who, when, thread structure) visible to relay operator",
5947
- "Single relay architecture (no onion routing, no mix network)",
5948
- "Ed25519 signatures are non-repudiable (no deniable messaging)",
5949
- "No async key agreement (no X3DH prekeys)",
5950
- "Polling-based (no WebSocket real-time transport)",
5951
- "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)"
6355
+ "Single relay architecture (no onion routing, no mix network \u2014 mitigated by multi-relay fallback)",
6356
+ "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6357
+ ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6358
+ ...!this.doubleRatchet ? ["Hash ratchet only \u2014 enable doubleRatchet option for post-compromise recovery"] : [],
6359
+ ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : []
5952
6360
  ]
5953
6361
  };
5954
6362
  }