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