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