@voidly/agent-sdk 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +137 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +663 -88
- package/dist/index.mjs +663 -88
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -3972,19 +3972,29 @@ async function ratchetStep(chainKey) {
|
|
|
3972
3972
|
);
|
|
3973
3973
|
return { nextChainKey, messageKey };
|
|
3974
3974
|
}
|
|
3975
|
-
function sealEnvelope(senderDid, plaintext) {
|
|
3976
|
-
|
|
3977
|
-
v:
|
|
3975
|
+
function sealEnvelope(senderDid, plaintext, meta) {
|
|
3976
|
+
const obj = {
|
|
3977
|
+
v: 3,
|
|
3978
3978
|
from: senderDid,
|
|
3979
3979
|
msg: plaintext,
|
|
3980
3980
|
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3981
|
-
}
|
|
3981
|
+
};
|
|
3982
|
+
if (meta?.contentType && meta.contentType !== "text/plain") obj.ct = meta.contentType;
|
|
3983
|
+
if (meta?.messageType && meta.messageType !== "text") obj.mt = meta.messageType;
|
|
3984
|
+
if (meta?.threadId) obj.tid = meta.threadId;
|
|
3985
|
+
if (meta?.replyTo) obj.rto = meta.replyTo;
|
|
3986
|
+
return JSON.stringify(obj);
|
|
3982
3987
|
}
|
|
3983
3988
|
function unsealEnvelope(plaintext) {
|
|
3984
3989
|
try {
|
|
3985
3990
|
const parsed = JSON.parse(plaintext);
|
|
3986
|
-
if (parsed.v === 2 && parsed.from && parsed.msg) {
|
|
3987
|
-
|
|
3991
|
+
if ((parsed.v === 2 || parsed.v === 3) && parsed.from && parsed.msg) {
|
|
3992
|
+
const result = { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
|
|
3993
|
+
if (parsed.ct) result.contentType = parsed.ct;
|
|
3994
|
+
if (parsed.mt) result.messageType = parsed.mt;
|
|
3995
|
+
if (parsed.tid) result.threadId = parsed.tid;
|
|
3996
|
+
if (parsed.rto) result.replyTo = parsed.rto;
|
|
3997
|
+
return result;
|
|
3988
3998
|
}
|
|
3989
3999
|
return null;
|
|
3990
4000
|
} catch {
|
|
@@ -4062,6 +4072,17 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4062
4072
|
this._identityCache = /* @__PURE__ */ new Map();
|
|
4063
4073
|
this._seenMessageIds = /* @__PURE__ */ new Set();
|
|
4064
4074
|
this._decryptFailCount = 0;
|
|
4075
|
+
// RPC handlers: method → handler function
|
|
4076
|
+
this._rpcHandlers = /* @__PURE__ */ new Map();
|
|
4077
|
+
// RPC pending responses: rpc_id → { resolve, reject, timer }
|
|
4078
|
+
this._rpcPending = /* @__PURE__ */ new Map();
|
|
4079
|
+
// Cover traffic state
|
|
4080
|
+
this._coverTrafficTimer = null;
|
|
4081
|
+
// RPC listener handle (started on first onInvoke)
|
|
4082
|
+
this._rpcListener = null;
|
|
4083
|
+
// Persistence (v3.2)
|
|
4084
|
+
this._persistMode = "memory";
|
|
4085
|
+
this._persistKey = null;
|
|
4065
4086
|
this.did = identity.did;
|
|
4066
4087
|
this.apiKey = identity.apiKey;
|
|
4067
4088
|
this.signingKeyPair = identity.signingKeyPair;
|
|
@@ -4081,6 +4102,11 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4081
4102
|
this.longPoll = config?.longPoll !== false;
|
|
4082
4103
|
this.mlkemPublicKey = identity.mlkemPublicKey || null;
|
|
4083
4104
|
this.mlkemSecretKey = identity.mlkemSecretKey || null;
|
|
4105
|
+
this._persistMode = config?.persist || "memory";
|
|
4106
|
+
this._onPersist = config?.onPersist;
|
|
4107
|
+
this._onLoad = config?.onLoad;
|
|
4108
|
+
this._persistPath = config?.persistPath;
|
|
4109
|
+
this._transportPrefs = config?.transport || ["sse", "long-poll"];
|
|
4084
4110
|
}
|
|
4085
4111
|
// ─── Factory Methods ────────────────────────────────────────────────────────
|
|
4086
4112
|
/**
|
|
@@ -4285,6 +4311,186 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4285
4311
|
} : {}
|
|
4286
4312
|
};
|
|
4287
4313
|
}
|
|
4314
|
+
// ─── Ratchet Persistence (v3.2) ────────────────────────────────────────────
|
|
4315
|
+
/** Derive persistence encryption key from signing secret */
|
|
4316
|
+
_derivePersistKey() {
|
|
4317
|
+
if (this._persistKey) return this._persistKey;
|
|
4318
|
+
const salt = (0, import_tweetnacl_util.decodeUTF8)("voidly-persist-v1");
|
|
4319
|
+
const input = new Uint8Array(this.signingKeyPair.secretKey.length + salt.length);
|
|
4320
|
+
input.set(this.signingKeyPair.secretKey, 0);
|
|
4321
|
+
input.set(salt, this.signingKeyPair.secretKey.length);
|
|
4322
|
+
this._persistKey = import_tweetnacl.default.hash(input).slice(0, 32);
|
|
4323
|
+
return this._persistKey;
|
|
4324
|
+
}
|
|
4325
|
+
/** Auto-persist ratchet state (called after every ratchet mutation) */
|
|
4326
|
+
async _persistRatchetState() {
|
|
4327
|
+
if (this._persistMode === "memory") return;
|
|
4328
|
+
try {
|
|
4329
|
+
const creds = this.exportCredentials();
|
|
4330
|
+
const data = JSON.stringify(creds.ratchetStates || {});
|
|
4331
|
+
const key = this._derivePersistKey();
|
|
4332
|
+
const nonce = import_tweetnacl.default.randomBytes(24);
|
|
4333
|
+
const encrypted = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(data), nonce, key);
|
|
4334
|
+
const blob = JSON.stringify({ n: (0, import_tweetnacl_util.encodeBase64)(nonce), c: (0, import_tweetnacl_util.encodeBase64)(encrypted), v: 1 });
|
|
4335
|
+
switch (this._persistMode) {
|
|
4336
|
+
case "localStorage":
|
|
4337
|
+
if (typeof localStorage !== "undefined") {
|
|
4338
|
+
localStorage.setItem(`voidly-ratchet-${this.did}`, blob);
|
|
4339
|
+
}
|
|
4340
|
+
break;
|
|
4341
|
+
case "indexedDB":
|
|
4342
|
+
await this._idbPut(blob);
|
|
4343
|
+
break;
|
|
4344
|
+
case "file":
|
|
4345
|
+
if (this._persistPath) {
|
|
4346
|
+
const fs = await import("fs/promises");
|
|
4347
|
+
await fs.writeFile(this._persistPath, blob, "utf-8");
|
|
4348
|
+
}
|
|
4349
|
+
break;
|
|
4350
|
+
case "relay":
|
|
4351
|
+
await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
|
|
4352
|
+
method: "PUT",
|
|
4353
|
+
headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
|
|
4354
|
+
body: JSON.stringify({ value: blob })
|
|
4355
|
+
}).catch(() => {
|
|
4356
|
+
});
|
|
4357
|
+
break;
|
|
4358
|
+
case "custom":
|
|
4359
|
+
if (this._onPersist) await this._onPersist(blob);
|
|
4360
|
+
break;
|
|
4361
|
+
}
|
|
4362
|
+
} catch {
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
/** Load persisted ratchet state and restore into memory */
|
|
4366
|
+
async _loadPersistedRatchetState() {
|
|
4367
|
+
if (this._persistMode === "memory") return;
|
|
4368
|
+
let blob = null;
|
|
4369
|
+
try {
|
|
4370
|
+
switch (this._persistMode) {
|
|
4371
|
+
case "localStorage":
|
|
4372
|
+
if (typeof localStorage !== "undefined") {
|
|
4373
|
+
blob = localStorage.getItem(`voidly-ratchet-${this.did}`);
|
|
4374
|
+
}
|
|
4375
|
+
break;
|
|
4376
|
+
case "indexedDB":
|
|
4377
|
+
blob = await this._idbGet();
|
|
4378
|
+
break;
|
|
4379
|
+
case "file":
|
|
4380
|
+
if (this._persistPath) {
|
|
4381
|
+
try {
|
|
4382
|
+
const fs = await import("fs/promises");
|
|
4383
|
+
blob = await fs.readFile(this._persistPath, "utf-8");
|
|
4384
|
+
} catch {
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
break;
|
|
4388
|
+
case "relay":
|
|
4389
|
+
try {
|
|
4390
|
+
const res = await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
|
|
4391
|
+
headers: { "X-Agent-Key": this.apiKey }
|
|
4392
|
+
});
|
|
4393
|
+
if (res.ok) {
|
|
4394
|
+
const data = await res.json();
|
|
4395
|
+
blob = data.value;
|
|
4396
|
+
}
|
|
4397
|
+
} catch {
|
|
4398
|
+
}
|
|
4399
|
+
break;
|
|
4400
|
+
case "custom":
|
|
4401
|
+
if (this._onLoad) blob = await this._onLoad();
|
|
4402
|
+
break;
|
|
4403
|
+
}
|
|
4404
|
+
} catch {
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
if (!blob) return;
|
|
4408
|
+
try {
|
|
4409
|
+
const { n, c } = JSON.parse(blob);
|
|
4410
|
+
const key = this._derivePersistKey();
|
|
4411
|
+
const decrypted = import_tweetnacl.default.secretbox.open((0, import_tweetnacl_util.decodeBase64)(c), (0, import_tweetnacl_util.decodeBase64)(n), key);
|
|
4412
|
+
if (!decrypted) return;
|
|
4413
|
+
const states = JSON.parse((0, import_tweetnacl_util.encodeUTF8)(decrypted));
|
|
4414
|
+
for (const [pairId, rs] of Object.entries(states)) {
|
|
4415
|
+
if (this._ratchetStates.has(pairId)) continue;
|
|
4416
|
+
const state = {
|
|
4417
|
+
sendChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey),
|
|
4418
|
+
sendStep: rs.sendStep,
|
|
4419
|
+
recvChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey),
|
|
4420
|
+
recvStep: rs.recvStep,
|
|
4421
|
+
skippedKeys: /* @__PURE__ */ new Map()
|
|
4422
|
+
};
|
|
4423
|
+
if (rs.rootKey) state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
|
|
4424
|
+
if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
|
|
4425
|
+
state.dhSendKeyPair = {
|
|
4426
|
+
secretKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey),
|
|
4427
|
+
publicKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey)
|
|
4428
|
+
};
|
|
4429
|
+
}
|
|
4430
|
+
if (rs.dhRecvPubKey) state.dhRecvPubKey = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
|
|
4431
|
+
if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
|
|
4432
|
+
if (state.sendChainKey.length === 32 && state.recvChainKey.length === 32) {
|
|
4433
|
+
this._ratchetStates.set(pairId, state);
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4436
|
+
} catch {
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
/** IndexedDB put helper (browser only) */
|
|
4440
|
+
async _idbPut(blob) {
|
|
4441
|
+
if (typeof indexedDB === "undefined") return;
|
|
4442
|
+
return new Promise((resolve, reject) => {
|
|
4443
|
+
const req = indexedDB.open("voidly-agent", 1);
|
|
4444
|
+
req.onupgradeneeded = () => {
|
|
4445
|
+
const db = req.result;
|
|
4446
|
+
if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
|
|
4447
|
+
};
|
|
4448
|
+
req.onsuccess = () => {
|
|
4449
|
+
const tx = req.result.transaction("ratchet", "readwrite");
|
|
4450
|
+
tx.objectStore("ratchet").put(blob, this.did);
|
|
4451
|
+
tx.oncomplete = () => resolve();
|
|
4452
|
+
tx.onerror = () => reject(tx.error);
|
|
4453
|
+
};
|
|
4454
|
+
req.onerror = () => reject(req.error);
|
|
4455
|
+
});
|
|
4456
|
+
}
|
|
4457
|
+
/** IndexedDB get helper (browser only) */
|
|
4458
|
+
async _idbGet() {
|
|
4459
|
+
if (typeof indexedDB === "undefined") return null;
|
|
4460
|
+
return new Promise((resolve, reject) => {
|
|
4461
|
+
const req = indexedDB.open("voidly-agent", 1);
|
|
4462
|
+
req.onupgradeneeded = () => {
|
|
4463
|
+
const db = req.result;
|
|
4464
|
+
if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
|
|
4465
|
+
};
|
|
4466
|
+
req.onsuccess = () => {
|
|
4467
|
+
const tx = req.result.transaction("ratchet", "readonly");
|
|
4468
|
+
const getReq = tx.objectStore("ratchet").get(this.did);
|
|
4469
|
+
getReq.onsuccess = () => resolve(getReq.result || null);
|
|
4470
|
+
getReq.onerror = () => reject(getReq.error);
|
|
4471
|
+
};
|
|
4472
|
+
req.onerror = () => reject(req.error);
|
|
4473
|
+
});
|
|
4474
|
+
}
|
|
4475
|
+
/**
|
|
4476
|
+
* Force-persist current ratchet state.
|
|
4477
|
+
* Useful to call before process exit to ensure state is saved.
|
|
4478
|
+
*/
|
|
4479
|
+
async flushRatchetState() {
|
|
4480
|
+
const origMode = this._persistMode;
|
|
4481
|
+
if (origMode === "memory") this._persistMode = "file";
|
|
4482
|
+
await this._persistRatchetState();
|
|
4483
|
+
this._persistMode = origMode;
|
|
4484
|
+
}
|
|
4485
|
+
/**
|
|
4486
|
+
* Restore an agent from credentials with async persistence loading.
|
|
4487
|
+
* Use this instead of `fromCredentials()` when using file/relay/custom persistence.
|
|
4488
|
+
*/
|
|
4489
|
+
static async fromCredentialsAsync(creds, config) {
|
|
4490
|
+
const agent = _VoidlyAgent.fromCredentials(creds, config);
|
|
4491
|
+
await agent._loadPersistedRatchetState();
|
|
4492
|
+
return agent;
|
|
4493
|
+
}
|
|
4288
4494
|
/**
|
|
4289
4495
|
* Get the number of messages that failed to decrypt.
|
|
4290
4496
|
* Useful for detecting key mismatches, attacks, or corruption.
|
|
@@ -4332,7 +4538,12 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4332
4538
|
const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
|
|
4333
4539
|
let plaintext = message;
|
|
4334
4540
|
if (useSealed) {
|
|
4335
|
-
plaintext = sealEnvelope(this.did, message
|
|
4541
|
+
plaintext = sealEnvelope(this.did, message, {
|
|
4542
|
+
contentType: options.contentType,
|
|
4543
|
+
messageType: options.messageType,
|
|
4544
|
+
threadId: options.threadId,
|
|
4545
|
+
replyTo: options.replyTo
|
|
4546
|
+
});
|
|
4336
4547
|
}
|
|
4337
4548
|
let contentBytes;
|
|
4338
4549
|
if (usePadding) {
|
|
@@ -4447,12 +4658,16 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4447
4658
|
nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
|
|
4448
4659
|
signature: (0, import_tweetnacl_util.encodeBase64)(signature),
|
|
4449
4660
|
envelope: envelopeData,
|
|
4450
|
-
content_type: options.contentType || "text/plain",
|
|
4451
|
-
message_type: options.messageType || "text",
|
|
4452
|
-
thread_id: options.threadId,
|
|
4453
|
-
reply_to: options.replyTo,
|
|
4454
4661
|
ttl: options.ttl
|
|
4455
4662
|
};
|
|
4663
|
+
if (!useSealed) {
|
|
4664
|
+
payload.content_type = options.contentType || "text/plain";
|
|
4665
|
+
payload.message_type = options.messageType || "text";
|
|
4666
|
+
payload.thread_id = options.threadId;
|
|
4667
|
+
payload.reply_to = options.replyTo;
|
|
4668
|
+
}
|
|
4669
|
+
this._persistRatchetState().catch(() => {
|
|
4670
|
+
});
|
|
4456
4671
|
const relays = [this.baseUrl, ...this.fallbackRelays];
|
|
4457
4672
|
let lastError = null;
|
|
4458
4673
|
for (const relay of relays) {
|
|
@@ -4503,7 +4718,7 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4503
4718
|
if (options.contentType) params.set("content_type", options.contentType);
|
|
4504
4719
|
if (options.messageType) params.set("message_type", options.messageType);
|
|
4505
4720
|
if (options.unreadOnly) params.set("unread", "true");
|
|
4506
|
-
const res = await this.
|
|
4721
|
+
const res = await this._resilientFetch(`/v1/agent/receive/raw?${params}`, {
|
|
4507
4722
|
headers: { "X-Agent-Key": this.apiKey }
|
|
4508
4723
|
});
|
|
4509
4724
|
if (!res.ok) {
|
|
@@ -4511,11 +4726,28 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4511
4726
|
throw new Error(`Receive failed: ${err.error?.message || err.error || res.statusText}`);
|
|
4512
4727
|
}
|
|
4513
4728
|
const data = await res.json();
|
|
4729
|
+
return this._decryptMessages(data.messages);
|
|
4730
|
+
}
|
|
4731
|
+
/** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
|
|
4732
|
+
async _decryptMessages(rawMessages) {
|
|
4514
4733
|
const decrypted = [];
|
|
4515
|
-
for (const msg of
|
|
4734
|
+
for (const msg of rawMessages) {
|
|
4516
4735
|
try {
|
|
4517
4736
|
if (this._seenMessageIds.has(msg.id)) continue;
|
|
4518
|
-
|
|
4737
|
+
let senderEncPub;
|
|
4738
|
+
let senderSignPubBytes = null;
|
|
4739
|
+
if (msg.sender_encryption_key) {
|
|
4740
|
+
senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
|
|
4741
|
+
if (msg.sender_signing_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
|
|
4742
|
+
} else if (msg.envelope) {
|
|
4743
|
+
const env = JSON.parse(msg.envelope);
|
|
4744
|
+
const senderProfile = await this.getIdentity(env.from);
|
|
4745
|
+
if (!senderProfile) continue;
|
|
4746
|
+
senderEncPub = (0, import_tweetnacl_util.decodeBase64)(senderProfile.encryption_public_key);
|
|
4747
|
+
if (senderProfile.signing_public_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(senderProfile.signing_public_key);
|
|
4748
|
+
} else {
|
|
4749
|
+
continue;
|
|
4750
|
+
}
|
|
4519
4751
|
const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
|
|
4520
4752
|
const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
|
|
4521
4753
|
let rawPlaintext = null;
|
|
@@ -4523,7 +4755,6 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4523
4755
|
let envelopePqCiphertext = null;
|
|
4524
4756
|
let envelopeDhRatchetKey = null;
|
|
4525
4757
|
let envelopePn = 0;
|
|
4526
|
-
let envelopeDeniable = false;
|
|
4527
4758
|
if (msg.envelope) {
|
|
4528
4759
|
try {
|
|
4529
4760
|
const env = JSON.parse(msg.envelope);
|
|
@@ -4683,11 +4914,19 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4683
4914
|
}
|
|
4684
4915
|
let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
|
|
4685
4916
|
let senderDid = msg.from;
|
|
4917
|
+
let innerContentType;
|
|
4918
|
+
let innerMessageType;
|
|
4919
|
+
let innerThreadId;
|
|
4920
|
+
let innerReplyTo;
|
|
4686
4921
|
if (wasSealed || !proto) {
|
|
4687
4922
|
const unsealed = unsealEnvelope(content);
|
|
4688
4923
|
if (unsealed) {
|
|
4689
4924
|
content = unsealed.msg;
|
|
4690
4925
|
senderDid = unsealed.from;
|
|
4926
|
+
innerContentType = unsealed.contentType;
|
|
4927
|
+
innerMessageType = unsealed.messageType;
|
|
4928
|
+
innerThreadId = unsealed.threadId;
|
|
4929
|
+
innerReplyTo = unsealed.replyTo;
|
|
4691
4930
|
}
|
|
4692
4931
|
}
|
|
4693
4932
|
let signatureValid = false;
|
|
@@ -4708,12 +4947,11 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4708
4947
|
for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
|
|
4709
4948
|
signatureValid = diff === 0;
|
|
4710
4949
|
}
|
|
4711
|
-
} else {
|
|
4712
|
-
const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
|
|
4950
|
+
} else if (senderSignPubBytes) {
|
|
4713
4951
|
signatureValid = import_tweetnacl.default.sign.detached.verify(
|
|
4714
4952
|
(0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
|
|
4715
4953
|
signatureBytes,
|
|
4716
|
-
|
|
4954
|
+
senderSignPubBytes
|
|
4717
4955
|
);
|
|
4718
4956
|
}
|
|
4719
4957
|
} catch {
|
|
@@ -4733,10 +4971,11 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4733
4971
|
from: senderDid,
|
|
4734
4972
|
to: msg.to,
|
|
4735
4973
|
content,
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4974
|
+
// v3: prefer metadata from inside ciphertext (relay can't see it)
|
|
4975
|
+
contentType: innerContentType || msg.content_type || "text/plain",
|
|
4976
|
+
messageType: innerMessageType || msg.message_type || "text",
|
|
4977
|
+
threadId: innerThreadId || msg.thread_id || null,
|
|
4978
|
+
replyTo: innerReplyTo || msg.reply_to || null,
|
|
4740
4979
|
signatureValid,
|
|
4741
4980
|
timestamp: msg.timestamp,
|
|
4742
4981
|
expiresAt: msg.expires_at
|
|
@@ -4745,6 +4984,10 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4745
4984
|
this._decryptFailCount++;
|
|
4746
4985
|
}
|
|
4747
4986
|
}
|
|
4987
|
+
if (decrypted.length > 0) {
|
|
4988
|
+
this._persistRatchetState().catch(() => {
|
|
4989
|
+
});
|
|
4990
|
+
}
|
|
4748
4991
|
return decrypted;
|
|
4749
4992
|
}
|
|
4750
4993
|
// ─── Message Management ─────────────────────────────────────────────────────
|
|
@@ -4797,7 +5040,7 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4797
5040
|
if (cached && Date.now() - cached.cachedAt < 3e5) {
|
|
4798
5041
|
return cached.profile;
|
|
4799
5042
|
}
|
|
4800
|
-
const res = await this.
|
|
5043
|
+
const res = await this._resilientFetch(`/v1/agent/identity/${did}`);
|
|
4801
5044
|
if (!res.ok) return null;
|
|
4802
5045
|
const profile = await res.json();
|
|
4803
5046
|
this._identityCache.set(did, { profile, cachedAt: Date.now() });
|
|
@@ -4815,7 +5058,7 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
4815
5058
|
if (options.query) params.set("query", options.query);
|
|
4816
5059
|
if (options.capability) params.set("capability", options.capability);
|
|
4817
5060
|
if (options.limit) params.set("limit", String(options.limit));
|
|
4818
|
-
const res = await this.
|
|
5061
|
+
const res = await this._resilientFetch(`/v1/agent/discover?${params}`);
|
|
4819
5062
|
if (!res.ok) return [];
|
|
4820
5063
|
const data = await res.json();
|
|
4821
5064
|
return data.agents;
|
|
@@ -6033,6 +6276,84 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
6033
6276
|
this.ping().catch(() => {
|
|
6034
6277
|
});
|
|
6035
6278
|
}
|
|
6279
|
+
const deliverMessages = async (messages) => {
|
|
6280
|
+
for (const msg of messages) {
|
|
6281
|
+
try {
|
|
6282
|
+
await onMessage(msg);
|
|
6283
|
+
if (autoMarkRead) {
|
|
6284
|
+
await this.markRead(msg.id).catch(() => {
|
|
6285
|
+
});
|
|
6286
|
+
}
|
|
6287
|
+
} catch (err) {
|
|
6288
|
+
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
|
6289
|
+
}
|
|
6290
|
+
}
|
|
6291
|
+
if (messages.length > 0) {
|
|
6292
|
+
lastSeen = messages[messages.length - 1].timestamp;
|
|
6293
|
+
}
|
|
6294
|
+
};
|
|
6295
|
+
const startSSE = async () => {
|
|
6296
|
+
try {
|
|
6297
|
+
const params = new URLSearchParams();
|
|
6298
|
+
if (lastSeen) params.set("since", lastSeen);
|
|
6299
|
+
if (options.from) params.set("from", options.from);
|
|
6300
|
+
const sseUrl = `${this.baseUrl}/v1/agent/receive/sse?${params}`;
|
|
6301
|
+
const res = await this._timedFetch(sseUrl, {
|
|
6302
|
+
headers: { "X-Agent-Key": this.apiKey }
|
|
6303
|
+
});
|
|
6304
|
+
if (!res.ok || !res.body) return false;
|
|
6305
|
+
const reader = res.body.getReader();
|
|
6306
|
+
const decoder = new TextDecoder();
|
|
6307
|
+
let buffer = "";
|
|
6308
|
+
while (active && !options.signal?.aborted) {
|
|
6309
|
+
const { done: streamDone, value } = await reader.read();
|
|
6310
|
+
if (streamDone) break;
|
|
6311
|
+
buffer += decoder.decode(value, { stream: true });
|
|
6312
|
+
const lines = buffer.split("\n");
|
|
6313
|
+
buffer = lines.pop() || "";
|
|
6314
|
+
let eventType = "";
|
|
6315
|
+
let dataStr = "";
|
|
6316
|
+
for (const line of lines) {
|
|
6317
|
+
if (line.startsWith("event: ")) {
|
|
6318
|
+
eventType = line.slice(7).trim();
|
|
6319
|
+
} else if (line.startsWith("data: ")) {
|
|
6320
|
+
dataStr = line.slice(6);
|
|
6321
|
+
} else if (line === "" && dataStr) {
|
|
6322
|
+
if (eventType === "message" && dataStr) {
|
|
6323
|
+
try {
|
|
6324
|
+
const rawMsg = JSON.parse(dataStr);
|
|
6325
|
+
const decrypted = await this._decryptMessages([rawMsg]);
|
|
6326
|
+
if (decrypted.length > 0) {
|
|
6327
|
+
consecutiveEmpty = 0;
|
|
6328
|
+
await deliverMessages(decrypted);
|
|
6329
|
+
}
|
|
6330
|
+
} catch {
|
|
6331
|
+
}
|
|
6332
|
+
} else if (eventType === "reconnect") {
|
|
6333
|
+
break;
|
|
6334
|
+
}
|
|
6335
|
+
eventType = "";
|
|
6336
|
+
dataStr = "";
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
}
|
|
6340
|
+
reader.releaseLock();
|
|
6341
|
+
return true;
|
|
6342
|
+
} catch {
|
|
6343
|
+
return false;
|
|
6344
|
+
}
|
|
6345
|
+
};
|
|
6346
|
+
const sseLoop = async () => {
|
|
6347
|
+
while (active && !options.signal?.aborted) {
|
|
6348
|
+
const ok = await startSSE();
|
|
6349
|
+
if (!active || options.signal?.aborted) break;
|
|
6350
|
+
if (!ok) {
|
|
6351
|
+
poll();
|
|
6352
|
+
return;
|
|
6353
|
+
}
|
|
6354
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
6355
|
+
}
|
|
6356
|
+
};
|
|
6036
6357
|
const useLongPoll = this.longPoll;
|
|
6037
6358
|
const poll = async () => {
|
|
6038
6359
|
if (!active || options.signal?.aborted) {
|
|
@@ -6040,62 +6361,18 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
6040
6361
|
return;
|
|
6041
6362
|
}
|
|
6042
6363
|
try {
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
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
|
-
}
|
|
6364
|
+
const messages = await this.receive({
|
|
6365
|
+
since: lastSeen,
|
|
6366
|
+
from: options.from,
|
|
6367
|
+
threadId: options.threadId,
|
|
6368
|
+
messageType: options.messageType,
|
|
6369
|
+
unreadOnly,
|
|
6370
|
+
limit: 50
|
|
6371
|
+
});
|
|
6084
6372
|
if (messages.length > 0) {
|
|
6085
6373
|
consecutiveEmpty = 0;
|
|
6086
6374
|
if (adaptive) currentInterval = Math.max(interval / 2, 500);
|
|
6087
|
-
|
|
6088
|
-
try {
|
|
6089
|
-
await onMessage(msg);
|
|
6090
|
-
if (autoMarkRead) {
|
|
6091
|
-
await this.markRead(msg.id).catch(() => {
|
|
6092
|
-
});
|
|
6093
|
-
}
|
|
6094
|
-
} catch (err) {
|
|
6095
|
-
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
|
6096
|
-
}
|
|
6097
|
-
}
|
|
6098
|
-
lastSeen = messages[messages.length - 1].timestamp;
|
|
6375
|
+
await deliverMessages(messages);
|
|
6099
6376
|
} else {
|
|
6100
6377
|
consecutiveEmpty++;
|
|
6101
6378
|
if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
|
|
@@ -6110,7 +6387,12 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
6110
6387
|
timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
|
|
6111
6388
|
}
|
|
6112
6389
|
};
|
|
6113
|
-
|
|
6390
|
+
const prefs = this._transportPrefs;
|
|
6391
|
+
if (prefs.includes("sse")) {
|
|
6392
|
+
sseLoop();
|
|
6393
|
+
} else {
|
|
6394
|
+
poll();
|
|
6395
|
+
}
|
|
6114
6396
|
return handle;
|
|
6115
6397
|
}
|
|
6116
6398
|
/**
|
|
@@ -6169,6 +6451,285 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
6169
6451
|
listener.stop();
|
|
6170
6452
|
}
|
|
6171
6453
|
this._listeners.clear();
|
|
6454
|
+
if (this._rpcListener) {
|
|
6455
|
+
this._rpcListener.stop();
|
|
6456
|
+
this._rpcListener = null;
|
|
6457
|
+
}
|
|
6458
|
+
for (const [id, pending] of this._rpcPending) {
|
|
6459
|
+
clearTimeout(pending.timer);
|
|
6460
|
+
pending.reject(new Error("Agent stopped"));
|
|
6461
|
+
}
|
|
6462
|
+
this._rpcPending.clear();
|
|
6463
|
+
this.disableCoverTraffic();
|
|
6464
|
+
}
|
|
6465
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6466
|
+
// AGENT RPC — Synchronous Function Invocation Between Agents
|
|
6467
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6468
|
+
/**
|
|
6469
|
+
* Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
|
|
6470
|
+
* The remote agent must have registered a handler via `onInvoke()`.
|
|
6471
|
+
*
|
|
6472
|
+
* @example
|
|
6473
|
+
* ```ts
|
|
6474
|
+
* // Call a translator agent
|
|
6475
|
+
* const result = await agent.invoke('did:voidly:translator', 'translate', {
|
|
6476
|
+
* text: 'Hello, world!',
|
|
6477
|
+
* to: 'ja',
|
|
6478
|
+
* });
|
|
6479
|
+
* console.log(result.translation); // こんにちは
|
|
6480
|
+
*
|
|
6481
|
+
* // With timeout
|
|
6482
|
+
* const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
|
|
6483
|
+
* ```
|
|
6484
|
+
*/
|
|
6485
|
+
async invoke(targetDid, method, params = {}, timeoutMs = 3e4) {
|
|
6486
|
+
const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
6487
|
+
return new Promise(async (resolve, reject) => {
|
|
6488
|
+
const timer = setTimeout(() => {
|
|
6489
|
+
this._rpcPending.delete(rpcId);
|
|
6490
|
+
reject(new Error(`RPC timeout: ${method}@${targetDid} after ${timeoutMs}ms`));
|
|
6491
|
+
}, timeoutMs);
|
|
6492
|
+
this._rpcPending.set(rpcId, { resolve, reject, timer });
|
|
6493
|
+
try {
|
|
6494
|
+
await this.send(targetDid, JSON.stringify({
|
|
6495
|
+
jsonrpc: "2.0",
|
|
6496
|
+
method,
|
|
6497
|
+
params,
|
|
6498
|
+
id: rpcId
|
|
6499
|
+
}), { messageType: "rpc-request", threadId: rpcId });
|
|
6500
|
+
} catch (err) {
|
|
6501
|
+
clearTimeout(timer);
|
|
6502
|
+
this._rpcPending.delete(rpcId);
|
|
6503
|
+
reject(err);
|
|
6504
|
+
}
|
|
6505
|
+
});
|
|
6506
|
+
}
|
|
6507
|
+
/**
|
|
6508
|
+
* Register a handler for incoming RPC invocations.
|
|
6509
|
+
* When another agent calls `invoke(yourDid, method, params)`, your handler runs.
|
|
6510
|
+
*
|
|
6511
|
+
* @example
|
|
6512
|
+
* ```ts
|
|
6513
|
+
* // Register a translation capability
|
|
6514
|
+
* agent.onInvoke('translate', async (params, callerDid) => {
|
|
6515
|
+
* const result = await myTranslateFunction(params.text, params.to);
|
|
6516
|
+
* return { translation: result };
|
|
6517
|
+
* });
|
|
6518
|
+
*
|
|
6519
|
+
* // Register a search capability
|
|
6520
|
+
* agent.onInvoke('search', async (params) => {
|
|
6521
|
+
* return { results: await searchDatabase(params.query) };
|
|
6522
|
+
* });
|
|
6523
|
+
* ```
|
|
6524
|
+
*/
|
|
6525
|
+
onInvoke(method, handler) {
|
|
6526
|
+
this._rpcHandlers.set(method, handler);
|
|
6527
|
+
this._ensureRpcListener();
|
|
6528
|
+
}
|
|
6529
|
+
/**
|
|
6530
|
+
* Remove an RPC handler.
|
|
6531
|
+
*/
|
|
6532
|
+
offInvoke(method) {
|
|
6533
|
+
this._rpcHandlers.delete(method);
|
|
6534
|
+
if (this._rpcHandlers.size === 0 && this._rpcListener) {
|
|
6535
|
+
this._rpcListener.stop();
|
|
6536
|
+
this._rpcListener = null;
|
|
6537
|
+
}
|
|
6538
|
+
}
|
|
6539
|
+
/** @internal Start listening for RPC requests and responses */
|
|
6540
|
+
_ensureRpcListener() {
|
|
6541
|
+
if (this._rpcListener) return;
|
|
6542
|
+
this._rpcListener = this.listen(async (msg) => {
|
|
6543
|
+
try {
|
|
6544
|
+
const payload = JSON.parse(msg.content);
|
|
6545
|
+
if (payload.jsonrpc !== "2.0") return;
|
|
6546
|
+
if (payload.id && (payload.result !== void 0 || payload.error)) {
|
|
6547
|
+
const pending = this._rpcPending.get(payload.id);
|
|
6548
|
+
if (pending) {
|
|
6549
|
+
clearTimeout(pending.timer);
|
|
6550
|
+
this._rpcPending.delete(payload.id);
|
|
6551
|
+
if (payload.error) {
|
|
6552
|
+
pending.reject(new Error(payload.error.message || "RPC error"));
|
|
6553
|
+
} else {
|
|
6554
|
+
pending.resolve(payload.result);
|
|
6555
|
+
}
|
|
6556
|
+
}
|
|
6557
|
+
return;
|
|
6558
|
+
}
|
|
6559
|
+
if (payload.method && payload.id) {
|
|
6560
|
+
const handler = this._rpcHandlers.get(payload.method);
|
|
6561
|
+
if (!handler) {
|
|
6562
|
+
await this.send(msg.from, JSON.stringify({
|
|
6563
|
+
jsonrpc: "2.0",
|
|
6564
|
+
id: payload.id,
|
|
6565
|
+
error: { code: -32601, message: `Method not found: ${payload.method}` }
|
|
6566
|
+
}), { messageType: "rpc-response", threadId: payload.id });
|
|
6567
|
+
return;
|
|
6568
|
+
}
|
|
6569
|
+
try {
|
|
6570
|
+
const result = await handler(payload.params || {}, msg.from);
|
|
6571
|
+
await this.send(msg.from, JSON.stringify({
|
|
6572
|
+
jsonrpc: "2.0",
|
|
6573
|
+
id: payload.id,
|
|
6574
|
+
result
|
|
6575
|
+
}), { messageType: "rpc-response", threadId: payload.id });
|
|
6576
|
+
} catch (err) {
|
|
6577
|
+
await this.send(msg.from, JSON.stringify({
|
|
6578
|
+
jsonrpc: "2.0",
|
|
6579
|
+
id: payload.id,
|
|
6580
|
+
error: { code: -32e3, message: err.message || "Handler error" }
|
|
6581
|
+
}), { messageType: "rpc-response", threadId: payload.id });
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
} catch {
|
|
6585
|
+
}
|
|
6586
|
+
}, { interval: 500, adaptive: false, heartbeat: false });
|
|
6587
|
+
}
|
|
6588
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6589
|
+
// P2P DIRECT MODE — Bypass Relay When Possible
|
|
6590
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6591
|
+
/**
|
|
6592
|
+
* Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
|
|
6593
|
+
* The relay never sees the message — true peer-to-peer encrypted delivery.
|
|
6594
|
+
*
|
|
6595
|
+
* Falls back to relay-based send if direct delivery fails.
|
|
6596
|
+
*
|
|
6597
|
+
* @example
|
|
6598
|
+
* ```ts
|
|
6599
|
+
* // Try direct first, fall back to relay
|
|
6600
|
+
* const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
|
|
6601
|
+
* console.log(result.direct); // true if delivered directly, false if via relay
|
|
6602
|
+
* ```
|
|
6603
|
+
*/
|
|
6604
|
+
async sendDirect(recipientDid, message, options = {}) {
|
|
6605
|
+
try {
|
|
6606
|
+
const profile = await this.getIdentity(recipientDid);
|
|
6607
|
+
if (profile) {
|
|
6608
|
+
const webhookRes = await this._timedFetch(
|
|
6609
|
+
`${this.baseUrl}/v1/agent/identity/${recipientDid}`,
|
|
6610
|
+
{ headers: { "X-Agent-Key": this.apiKey } }
|
|
6611
|
+
);
|
|
6612
|
+
if (webhookRes.ok) {
|
|
6613
|
+
const data = await webhookRes.json();
|
|
6614
|
+
const webhookUrl = data.webhook_url;
|
|
6615
|
+
if (webhookUrl) {
|
|
6616
|
+
const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
|
|
6617
|
+
const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
|
|
6618
|
+
const plaintext = (0, import_tweetnacl_util.decodeUTF8)(message);
|
|
6619
|
+
const ciphertext = import_tweetnacl.default.box(plaintext, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
|
|
6620
|
+
const envelope = JSON.stringify({
|
|
6621
|
+
from: this.did,
|
|
6622
|
+
to: recipientDid,
|
|
6623
|
+
ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
|
|
6624
|
+
nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
|
|
6625
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6626
|
+
message_type: options.messageType || "text",
|
|
6627
|
+
thread_id: options.threadId
|
|
6628
|
+
});
|
|
6629
|
+
const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelope), this.signingKeyPair.secretKey);
|
|
6630
|
+
const directRes = await this._timedFetch(webhookUrl, {
|
|
6631
|
+
method: "POST",
|
|
6632
|
+
headers: {
|
|
6633
|
+
"Content-Type": "application/json",
|
|
6634
|
+
"X-Voidly-Signature": `sha256=${(0, import_tweetnacl_util.encodeBase64)(signature)}`,
|
|
6635
|
+
"X-Voidly-Sender": this.did
|
|
6636
|
+
},
|
|
6637
|
+
body: envelope
|
|
6638
|
+
});
|
|
6639
|
+
if (directRes.ok) {
|
|
6640
|
+
const now = /* @__PURE__ */ new Date();
|
|
6641
|
+
return {
|
|
6642
|
+
id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
6643
|
+
from: this.did,
|
|
6644
|
+
to: recipientDid,
|
|
6645
|
+
timestamp: now.toISOString(),
|
|
6646
|
+
expiresAt: new Date(now.getTime() + 864e5).toISOString(),
|
|
6647
|
+
encrypted: true,
|
|
6648
|
+
clientSide: true,
|
|
6649
|
+
direct: true
|
|
6650
|
+
};
|
|
6651
|
+
}
|
|
6652
|
+
}
|
|
6653
|
+
}
|
|
6654
|
+
}
|
|
6655
|
+
} catch {
|
|
6656
|
+
}
|
|
6657
|
+
const result = await this.send(recipientDid, message, options);
|
|
6658
|
+
return { ...result, direct: false };
|
|
6659
|
+
}
|
|
6660
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6661
|
+
// COVER TRAFFIC — Noise Protocol for Traffic Analysis Resistance
|
|
6662
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6663
|
+
/**
|
|
6664
|
+
* Enable cover traffic — sends encrypted noise at random intervals.
|
|
6665
|
+
* Makes real messages indistinguishable from cover traffic for any observer
|
|
6666
|
+
* monitoring message timing and frequency.
|
|
6667
|
+
*
|
|
6668
|
+
* Cover messages are encrypted and padded identically to real messages.
|
|
6669
|
+
* The relay cannot distinguish them from real traffic.
|
|
6670
|
+
*
|
|
6671
|
+
* @example
|
|
6672
|
+
* ```ts
|
|
6673
|
+
* // Send noise every ~30s (randomized ±50%)
|
|
6674
|
+
* agent.enableCoverTraffic({ intervalMs: 30000 });
|
|
6675
|
+
*
|
|
6676
|
+
* // Stop cover traffic
|
|
6677
|
+
* agent.disableCoverTraffic();
|
|
6678
|
+
* ```
|
|
6679
|
+
*/
|
|
6680
|
+
enableCoverTraffic(options = {}) {
|
|
6681
|
+
this.disableCoverTraffic();
|
|
6682
|
+
const baseInterval = options.intervalMs || 3e4;
|
|
6683
|
+
const sendNoise = async () => {
|
|
6684
|
+
try {
|
|
6685
|
+
const noise = import_tweetnacl.default.randomBytes(128 + Math.floor(Math.random() * 384));
|
|
6686
|
+
await this.send(this.did, (0, import_tweetnacl_util.encodeBase64)(noise), {
|
|
6687
|
+
messageType: "ping",
|
|
6688
|
+
// use 'ping' type — indistinguishable in encrypted payload
|
|
6689
|
+
ttl: 60
|
|
6690
|
+
// short TTL — noise auto-expires
|
|
6691
|
+
});
|
|
6692
|
+
} catch {
|
|
6693
|
+
}
|
|
6694
|
+
};
|
|
6695
|
+
const scheduleNext = () => {
|
|
6696
|
+
const jitter = baseInterval * (0.5 + Math.random());
|
|
6697
|
+
this._coverTrafficTimer = setTimeout(async () => {
|
|
6698
|
+
await sendNoise();
|
|
6699
|
+
if (this._coverTrafficTimer !== null) scheduleNext();
|
|
6700
|
+
}, jitter);
|
|
6701
|
+
};
|
|
6702
|
+
scheduleNext();
|
|
6703
|
+
}
|
|
6704
|
+
/**
|
|
6705
|
+
* Disable cover traffic.
|
|
6706
|
+
*/
|
|
6707
|
+
disableCoverTraffic() {
|
|
6708
|
+
if (this._coverTrafficTimer !== null) {
|
|
6709
|
+
clearTimeout(this._coverTrafficTimer);
|
|
6710
|
+
this._coverTrafficTimer = null;
|
|
6711
|
+
}
|
|
6712
|
+
}
|
|
6713
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6714
|
+
// RESILIENT OPERATIONS — Fallback for All Operations
|
|
6715
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6716
|
+
/**
|
|
6717
|
+
* Fetch from primary relay with fallback to alternate relays.
|
|
6718
|
+
* Unlike _timedFetch which only hits one URL, this tries all known relays.
|
|
6719
|
+
* @internal
|
|
6720
|
+
*/
|
|
6721
|
+
async _resilientFetch(path, init) {
|
|
6722
|
+
const relays = [this.baseUrl, ...this.fallbackRelays];
|
|
6723
|
+
let lastError = null;
|
|
6724
|
+
for (const relay of relays) {
|
|
6725
|
+
try {
|
|
6726
|
+
const res = await this._timedFetch(`${relay}${path}`, init);
|
|
6727
|
+
if (res.ok || res.status >= 400 && res.status < 500) return res;
|
|
6728
|
+
} catch (err) {
|
|
6729
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
6730
|
+
}
|
|
6731
|
+
}
|
|
6732
|
+
throw lastError || new Error("All relays failed");
|
|
6172
6733
|
}
|
|
6173
6734
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
6174
6735
|
// CONVERSATIONS — Thread Management
|
|
@@ -6307,13 +6868,11 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
6307
6868
|
return {
|
|
6308
6869
|
relayCanSee: [
|
|
6309
6870
|
"Your DID (public identifier)",
|
|
6310
|
-
|
|
6871
|
+
"Recipient DIDs (relay needs them for routing)",
|
|
6311
6872
|
...this.jitterMs > 0 ? ["Approximate timing (jittered timestamps)"] : ["When you message (timestamps)"],
|
|
6312
|
-
"Message types
|
|
6313
|
-
"Thread structure (which messages are replies)",
|
|
6873
|
+
...this.sealedSender ? [] : ["Message types, thread IDs, content types (sent in cleartext without sealed sender)"],
|
|
6314
6874
|
"Channel membership (but NOT channel message content with client-side encryption)",
|
|
6315
6875
|
"Capability registrations",
|
|
6316
|
-
"Online/offline status",
|
|
6317
6876
|
"Approximate message size (even with padding, bounded to power-of-2)"
|
|
6318
6877
|
],
|
|
6319
6878
|
relayCannotSee: [
|
|
@@ -6323,7 +6882,11 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
6323
6882
|
"Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
|
|
6324
6883
|
"Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
|
|
6325
6884
|
"Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
|
|
6326
|
-
...this.sealedSender ? [
|
|
6885
|
+
...this.sealedSender ? [
|
|
6886
|
+
'Sender identity (sealed inside ciphertext \u2014 relay stores "sealed" not your DID)',
|
|
6887
|
+
"Message types, thread IDs, reply chains (packed inside ciphertext in v3)",
|
|
6888
|
+
"Message count (not incremented for sealed senders)"
|
|
6889
|
+
] : [],
|
|
6327
6890
|
...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
|
|
6328
6891
|
],
|
|
6329
6892
|
protections: [
|
|
@@ -6341,22 +6904,34 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
6341
6904
|
"Request timeouts (AbortController on all HTTP, configurable)",
|
|
6342
6905
|
"Request validation (fromCredentials validates key sizes and format)",
|
|
6343
6906
|
...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
|
|
6344
|
-
...this.sealedSender ? [
|
|
6907
|
+
...this.sealedSender ? [
|
|
6908
|
+
"Sealed sender (relay cannot see who sent a message)",
|
|
6909
|
+
"Metadata privacy (v3 \u2014 thread_id, message_type, reply_to packed inside ciphertext, stripped from relay storage)"
|
|
6910
|
+
] : [],
|
|
6345
6911
|
...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
|
|
6346
|
-
...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
|
|
6912
|
+
...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
|
|
6347
6913
|
...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
|
|
6914
|
+
...this._transportPrefs.includes("sse") ? ["SSE streaming transport (real-time push delivery from relay)"] : [],
|
|
6348
6915
|
...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
|
|
6916
|
+
...this._persistMode !== "memory" ? [`Ratchet state auto-persistence (${this._persistMode} backend \u2014 survives process restart)`] : [],
|
|
6917
|
+
...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
|
|
6918
|
+
"Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
|
|
6919
|
+
"P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
|
|
6920
|
+
"Resilient operations (receive, discover, identity \u2014 all try fallback relays)",
|
|
6349
6921
|
"Auto-retry with exponential backoff",
|
|
6350
6922
|
"Offline message queue",
|
|
6351
6923
|
"did:key interoperability (W3C standard DID format)"
|
|
6352
6924
|
],
|
|
6353
6925
|
gaps: [
|
|
6354
6926
|
...!this.postQuantum || !this.mlkemPublicKey ? ["No post-quantum protection \u2014 enable postQuantum option and re-register"] : [],
|
|
6355
|
-
"
|
|
6356
|
-
"
|
|
6927
|
+
...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"],
|
|
6928
|
+
"Relay sees channel membership, task delegation, trust scores (social graph)",
|
|
6929
|
+
...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
|
|
6930
|
+
...this._persistMode === "memory" ? ["Ratchet state is in-memory (lost on process restart \u2014 use persist option or exportCredentials)"] : [],
|
|
6357
6931
|
...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
|
|
6358
6932
|
...!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"] : []
|
|
6933
|
+
...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
|
|
6934
|
+
...this._coverTrafficTimer === null ? ["No cover traffic \u2014 call enableCoverTraffic() to resist traffic analysis"] : []
|
|
6360
6935
|
]
|
|
6361
6936
|
};
|
|
6362
6937
|
}
|