@ninelives/agent-governance-sdk 5.0.1
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/README.md +363 -0
- package/dist/audit.d.ts +26 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +107 -0
- package/dist/audit.js.map +1 -0
- package/dist/cascade-containment.d.ts +84 -0
- package/dist/cascade-containment.d.ts.map +1 -0
- package/dist/cascade-containment.js +387 -0
- package/dist/cascade-containment.js.map +1 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +112 -0
- package/dist/client.js.map +1 -0
- package/dist/context-poisoning.d.ts +58 -0
- package/dist/context-poisoning.d.ts.map +1 -0
- package/dist/context-poisoning.js +410 -0
- package/dist/context-poisoning.js.map +1 -0
- package/dist/credential-vault.d.ts +213 -0
- package/dist/credential-vault.d.ts.map +1 -0
- package/dist/credential-vault.js +567 -0
- package/dist/credential-vault.js.map +1 -0
- package/dist/discovery.d.ts +80 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +404 -0
- package/dist/discovery.js.map +1 -0
- package/dist/encryption/channel.d.ts +29 -0
- package/dist/encryption/channel.d.ts.map +1 -0
- package/dist/encryption/channel.js +78 -0
- package/dist/encryption/channel.js.map +1 -0
- package/dist/encryption/index.d.ts +9 -0
- package/dist/encryption/index.d.ts.map +1 -0
- package/dist/encryption/index.js +23 -0
- package/dist/encryption/index.js.map +1 -0
- package/dist/encryption/mesh-client.d.ts +281 -0
- package/dist/encryption/mesh-client.d.ts.map +1 -0
- package/dist/encryption/mesh-client.js +1110 -0
- package/dist/encryption/mesh-client.js.map +1 -0
- package/dist/encryption/ratchet.d.ts +38 -0
- package/dist/encryption/ratchet.d.ts.map +1 -0
- package/dist/encryption/ratchet.js +223 -0
- package/dist/encryption/ratchet.js.map +1 -0
- package/dist/encryption/registry-client.d.ts +136 -0
- package/dist/encryption/registry-client.d.ts.map +1 -0
- package/dist/encryption/registry-client.js +369 -0
- package/dist/encryption/registry-client.js.map +1 -0
- package/dist/encryption/x3dh.d.ts +80 -0
- package/dist/encryption/x3dh.d.ts.map +1 -0
- package/dist/encryption/x3dh.js +238 -0
- package/dist/encryption/x3dh.js.map +1 -0
- package/dist/framework-adapter.d.ts +63 -0
- package/dist/framework-adapter.d.ts.map +1 -0
- package/dist/framework-adapter.js +253 -0
- package/dist/framework-adapter.js.map +1 -0
- package/dist/identity.d.ts +124 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +446 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +95 -0
- package/dist/index.js.map +1 -0
- package/dist/kill-switch.d.ts +22 -0
- package/dist/kill-switch.d.ts.map +1 -0
- package/dist/kill-switch.js +60 -0
- package/dist/kill-switch.js.map +1 -0
- package/dist/lifecycle.d.ts +50 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +113 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/mcp.d.ts +41 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +233 -0
- package/dist/mcp.js.map +1 -0
- package/dist/metrics.d.ts +123 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +289 -0
- package/dist/metrics.js.map +1 -0
- package/dist/oci-manifest.d.ts +49 -0
- package/dist/oci-manifest.d.ts.map +1 -0
- package/dist/oci-manifest.js +182 -0
- package/dist/oci-manifest.js.map +1 -0
- package/dist/policy-backends/cedar.d.ts +15 -0
- package/dist/policy-backends/cedar.d.ts.map +1 -0
- package/dist/policy-backends/cedar.js +88 -0
- package/dist/policy-backends/cedar.js.map +1 -0
- package/dist/policy-backends/opa.d.ts +17 -0
- package/dist/policy-backends/opa.d.ts.map +1 -0
- package/dist/policy-backends/opa.js +74 -0
- package/dist/policy-backends/opa.js.map +1 -0
- package/dist/policy.d.ts +77 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +760 -0
- package/dist/policy.js.map +1 -0
- package/dist/prompt-defense.d.ts +37 -0
- package/dist/prompt-defense.d.ts.map +1 -0
- package/dist/prompt-defense.js +229 -0
- package/dist/prompt-defense.js.map +1 -0
- package/dist/protocol-facets.d.ts +87 -0
- package/dist/protocol-facets.d.ts.map +1 -0
- package/dist/protocol-facets.js +728 -0
- package/dist/protocol-facets.js.map +1 -0
- package/dist/rings.d.ts +17 -0
- package/dist/rings.d.ts.map +1 -0
- package/dist/rings.js +61 -0
- package/dist/rings.js.map +1 -0
- package/dist/sandbox.d.ts +61 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +199 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/surface-parity.d.ts +45 -0
- package/dist/surface-parity.d.ts.map +1 -0
- package/dist/surface-parity.js +216 -0
- package/dist/surface-parity.js.map +1 -0
- package/dist/trust.d.ts +28 -0
- package/dist/trust.d.ts.map +1 -0
- package/dist/trust.js +209 -0
- package/dist/trust.js.map +1 -0
- package/dist/types.d.ts +422 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +135 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +356 -0
- package/dist/verify.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) Microsoft Corporation.
|
|
3
|
+
// Licensed under the MIT License.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.MeshClient = exports.WS_CLOSE_SESSION_REPLACED = void 0;
|
|
6
|
+
/**
|
|
7
|
+
* AgentMesh transport client — WebSocket connection to relay with
|
|
8
|
+
* plaintext-peer support, KNOCK pending queue, and wsFactory hook.
|
|
9
|
+
*
|
|
10
|
+
* Spec: docs/specs/AGENTMESH-WIRE-1.0.md Sections 9, 10, 12
|
|
11
|
+
*
|
|
12
|
+
* Features added for AzureClaw compatibility:
|
|
13
|
+
* - plaintextPeers: bypass E2E encryption for legacy peers (e.g., Rust controller)
|
|
14
|
+
* - wsFactory: custom WebSocket constructor for HTTPS_PROXY CONNECT tunneling
|
|
15
|
+
* - KNOCK pending queue: handle race between KNOCK and first message
|
|
16
|
+
*/
|
|
17
|
+
const channel_1 = require("./channel");
|
|
18
|
+
const registry_client_1 = require("./registry-client");
|
|
19
|
+
/**
|
|
20
|
+
* Derive the canonical AGT-main agent DID from an Ed25519 public key.
|
|
21
|
+
*
|
|
22
|
+
* Format: `did:mesh:<sha256(public_key)[:32]>` (32 hex chars = 16 bytes
|
|
23
|
+
* of digest). Matches the server's derivation in
|
|
24
|
+
* `agent-governance-python/agent-mesh/src/agentmesh/registry/app.py`
|
|
25
|
+
* (`key_hash = hashlib.sha256(public_key).hexdigest()[:32]`).
|
|
26
|
+
*
|
|
27
|
+
* If `fallback` looks like a `did:mesh:` already (callers that compute
|
|
28
|
+
* it themselves), prefer that; otherwise compute fresh from the public
|
|
29
|
+
* key. Sync function — relies on Node's `node:crypto` or the browser's
|
|
30
|
+
* SubtleCrypto can't be used synchronously so we hand-roll SHA-256 if
|
|
31
|
+
* neither is available. In practice the SDK only runs in Node so this
|
|
32
|
+
* just falls through to `require("node:crypto")`.
|
|
33
|
+
*/
|
|
34
|
+
function computeCanonicalDid(ed25519Public, fallback) {
|
|
35
|
+
if (fallback && fallback.startsWith("did:mesh:"))
|
|
36
|
+
return fallback;
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const nodeCrypto = require("node:crypto");
|
|
39
|
+
const hex = nodeCrypto.createHash("sha256").update(ed25519Public).digest("hex");
|
|
40
|
+
return `did:mesh:${hex.slice(0, 32)}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* WebSocket close code sent by the relay when another connection
|
|
44
|
+
* authenticates for the same DID and displaces this socket.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately not 1000 (Normal Closure): 1000 is indistinguishable from the
|
|
47
|
+
* client closing its own socket, which made a mailbox takeover silent and
|
|
48
|
+
* unattributable. Clients receive this as a server-initiated close but must
|
|
49
|
+
* NOT auto-reconnect on it — reconnecting would displace the replacing socket
|
|
50
|
+
* and the two would evict each other in a loop.
|
|
51
|
+
*
|
|
52
|
+
* Must stay in sync with `WS_CLOSE_SESSION_REPLACED` in the relay
|
|
53
|
+
* (`agentmesh/relay/app.py`).
|
|
54
|
+
*/
|
|
55
|
+
exports.WS_CLOSE_SESSION_REPLACED = 4006;
|
|
56
|
+
/**
|
|
57
|
+
* High-level mesh client for agent-to-agent communication.
|
|
58
|
+
*
|
|
59
|
+
* Manages WebSocket connection to the relay, session establishment
|
|
60
|
+
* (KNOCK + X3DH), and message encryption/decryption. Supports
|
|
61
|
+
* plaintext peers for legacy interop.
|
|
62
|
+
*/
|
|
63
|
+
class MeshClient {
|
|
64
|
+
options;
|
|
65
|
+
sessions = new Map();
|
|
66
|
+
plaintextPeers;
|
|
67
|
+
knockPending = new Map();
|
|
68
|
+
knockAccepted = new Set();
|
|
69
|
+
messageHandlers = [];
|
|
70
|
+
knockHandlers = [];
|
|
71
|
+
errorHandlers = [];
|
|
72
|
+
disconnectHandlers = [];
|
|
73
|
+
e2eVerifiedHandlers = [];
|
|
74
|
+
/** Tracks peers whose first encrypted message we've seen — feeds onE2EVerified. */
|
|
75
|
+
e2eVerifiedSet = new Set();
|
|
76
|
+
ws = null;
|
|
77
|
+
connected = false;
|
|
78
|
+
knockTimeout;
|
|
79
|
+
autoReconnect;
|
|
80
|
+
maxReconnectAttempts;
|
|
81
|
+
reconnectBaseDelayMs;
|
|
82
|
+
reconnectMaxDelayMs;
|
|
83
|
+
reconnectAttempts = 0;
|
|
84
|
+
reconnectTimer = null;
|
|
85
|
+
clientInitiatedClose = false;
|
|
86
|
+
preKnockBufferSize;
|
|
87
|
+
preKnockBufferTtlMs;
|
|
88
|
+
maxBufferedPeers;
|
|
89
|
+
/**
|
|
90
|
+
* Per-peer buffer for encrypted message frames that arrived before the
|
|
91
|
+
* peer's KNOCK was processed. Drained when the KNOCK is later accepted.
|
|
92
|
+
* Mirrors vendored agentmesh-sdk patch #16.
|
|
93
|
+
*/
|
|
94
|
+
preKnockBuffer = new Map();
|
|
95
|
+
registry;
|
|
96
|
+
autoRegister;
|
|
97
|
+
oneTimePrekeyCount;
|
|
98
|
+
registered = false;
|
|
99
|
+
/**
|
|
100
|
+
* The agent DID used on every wire-level send (`connect.from`,
|
|
101
|
+
* `mesh_send.from`, `knock.from`, …). Starts at `options.agentDid` so
|
|
102
|
+
* back-compat is preserved when no registry is configured or when
|
|
103
|
+
* registering against a pre-2533 registry. When `registerSelf()` runs
|
|
104
|
+
* against a POP-aware registry (AGT main since 2026-05-23), the server
|
|
105
|
+
* returns the canonical `did:mesh:<sha256(public_key)[:32]>` and we
|
|
106
|
+
* adopt it here — every subsequent frame uses the canonical form so
|
|
107
|
+
* relay and registry lookups agree.
|
|
108
|
+
*/
|
|
109
|
+
activeDid;
|
|
110
|
+
/**
|
|
111
|
+
* Public read-only view of the active DID. Caller code that previously
|
|
112
|
+
* read `options.agentDid` and stored it should read this instead so
|
|
113
|
+
* post-registration DID swaps are visible.
|
|
114
|
+
*/
|
|
115
|
+
get currentDid() {
|
|
116
|
+
return this.activeDid;
|
|
117
|
+
}
|
|
118
|
+
constructor(options) {
|
|
119
|
+
this.options = options;
|
|
120
|
+
// Compute the canonical AGT main DID locally so the connect frame
|
|
121
|
+
// can include the matching `from` field even on the very first
|
|
122
|
+
// connect (registerSelf runs AFTER connect resolves; the relay's
|
|
123
|
+
// POP gate validates `from == "did:mesh:" + sha256(public_key)[:32]`
|
|
124
|
+
// on the connect frame, NOT on the eventual server-derived DID).
|
|
125
|
+
// We replace whatever caller-supplied DID was passed because the
|
|
126
|
+
// POP-aware relay/registry will reject any other format. Pre-POP
|
|
127
|
+
// deployments don't care, so this is back-compat-safe.
|
|
128
|
+
this.activeDid = computeCanonicalDid(options.keyManager.identityKeyEd, options.agentDid);
|
|
129
|
+
this.plaintextPeers = new Set(options.plaintextPeers ?? []);
|
|
130
|
+
this.knockTimeout = options.knockTimeout ?? 10_000;
|
|
131
|
+
this.autoReconnect = options.autoReconnect ?? true;
|
|
132
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
|
|
133
|
+
this.reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? 1000;
|
|
134
|
+
this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 60_000;
|
|
135
|
+
this.preKnockBufferSize = options.preKnockBufferSize ?? 5;
|
|
136
|
+
this.preKnockBufferTtlMs = options.preKnockBufferTtlMs ?? 3_000;
|
|
137
|
+
this.maxBufferedPeers = options.maxBufferedPeers ?? 100;
|
|
138
|
+
this.autoRegister = options.autoRegister ?? true;
|
|
139
|
+
this.oneTimePrekeyCount = options.oneTimePrekeyCount ?? 20;
|
|
140
|
+
if (options.registryClient) {
|
|
141
|
+
this.registry = options.registryClient;
|
|
142
|
+
}
|
|
143
|
+
else if (options.registryUrl) {
|
|
144
|
+
// Wire an Ed25519-Timestamp signer to every registry call so
|
|
145
|
+
// `PUT /v1/agents/{did}/prekeys` and the other authed endpoints
|
|
146
|
+
// (heartbeat, reputation) pass `verify_ed25519_timestamp_auth`.
|
|
147
|
+
// The signer uses the same identity key the relay POP / connect-frame
|
|
148
|
+
// signature uses, so the registry can map `authed_did == activeDid`.
|
|
149
|
+
// The caller can override by passing their own `authSigner` via
|
|
150
|
+
// `registryClientOptions`.
|
|
151
|
+
const callerOpts = options.registryClientOptions ?? {};
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
153
|
+
const self = this;
|
|
154
|
+
const authSigner = callerOpts.authSigner ?? {
|
|
155
|
+
get did() { return self.activeDid; }, // late-bind: DID can change after register
|
|
156
|
+
sign: (m) => options.keyManager.signMessage(m),
|
|
157
|
+
};
|
|
158
|
+
this.registry = new registry_client_1.RegistryClient({
|
|
159
|
+
baseUrl: options.registryUrl,
|
|
160
|
+
...callerOpts,
|
|
161
|
+
authSigner,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
this.registry = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Access the RegistryClient (built from registryUrl or injected via
|
|
170
|
+
* registryClient). Returns null only if MeshClient was constructed
|
|
171
|
+
* without a registry URL or client — which means discover/peer lookup
|
|
172
|
+
* features are disabled.
|
|
173
|
+
*/
|
|
174
|
+
getRegistry() {
|
|
175
|
+
return this.registry;
|
|
176
|
+
}
|
|
177
|
+
// ── Plaintext peers ─────────────────────────────────────────────
|
|
178
|
+
addPlaintextPeer(peerId) {
|
|
179
|
+
this.plaintextPeers.add(peerId);
|
|
180
|
+
}
|
|
181
|
+
removePlaintextPeer(peerId) {
|
|
182
|
+
this.plaintextPeers.delete(peerId);
|
|
183
|
+
}
|
|
184
|
+
isPlaintextPeer(peerId) {
|
|
185
|
+
return this.plaintextPeers.has(peerId);
|
|
186
|
+
}
|
|
187
|
+
// ── Connection ──────────────────────────────────────────────────
|
|
188
|
+
async connect() {
|
|
189
|
+
if (this.connected)
|
|
190
|
+
return;
|
|
191
|
+
const wsUrl = this.options.relayUrl.replace(/^http/, "ws") + "/ws";
|
|
192
|
+
const wsFactory = this.options.wsFactory ?? ((url) => new WebSocket(url));
|
|
193
|
+
this.ws = wsFactory(wsUrl);
|
|
194
|
+
await new Promise((resolve, reject) => {
|
|
195
|
+
this.ws.onopen = () => {
|
|
196
|
+
this.connected = true;
|
|
197
|
+
// POP-enabled connect frame (AGT main since 2026-05-23 PR #2632).
|
|
198
|
+
// Pre-POP relays ignore the extra fields — back-compat preserved.
|
|
199
|
+
// The relay verifies:
|
|
200
|
+
// - DID equals `did:mesh:` + sha256(public_key)[:32]
|
|
201
|
+
// - Ed25519 signature over the timestamp string (NOT pub||ts)
|
|
202
|
+
// -- distinct from registry POP which signs pub||ts
|
|
203
|
+
//
|
|
204
|
+
// Encoding gotcha: the relay decodes with stdlib `base64.b64decode`
|
|
205
|
+
// (standard base64 with +/=), while the registry decodes with
|
|
206
|
+
// `urlsafe_b64decode` (base64url with -_). Mismatch in upstream
|
|
207
|
+
// server code; we have to match each endpoint's expectation
|
|
208
|
+
// separately. Emitting standard base64 here so the relay's
|
|
209
|
+
// `base64.b64decode(pub_b64)` and signature decode succeed.
|
|
210
|
+
const pubB64 = Buffer.from(this.options.keyManager.identityKeyEd).toString("base64");
|
|
211
|
+
const tsStr = new Date().toISOString();
|
|
212
|
+
const sig = this.options.keyManager.signMessage(new TextEncoder().encode(tsStr));
|
|
213
|
+
const sigB64 = Buffer.from(sig).toString("base64");
|
|
214
|
+
this.sendFrame({
|
|
215
|
+
v: 1,
|
|
216
|
+
type: "connect",
|
|
217
|
+
from: this.activeDid,
|
|
218
|
+
public_key: pubB64,
|
|
219
|
+
timestamp: tsStr,
|
|
220
|
+
signature: sigB64,
|
|
221
|
+
});
|
|
222
|
+
// Request any messages queued while offline (inbox replay)
|
|
223
|
+
this.sendFrame({
|
|
224
|
+
v: 1,
|
|
225
|
+
type: "fetch_pending",
|
|
226
|
+
from: this.activeDid,
|
|
227
|
+
});
|
|
228
|
+
resolve();
|
|
229
|
+
};
|
|
230
|
+
this.ws.onerror = (e) => {
|
|
231
|
+
// Surface to AzureClaw-style observers BEFORE rejecting connect.
|
|
232
|
+
// Once connect resolves, subsequent ws errors flow through this same path.
|
|
233
|
+
const errEvent = e;
|
|
234
|
+
const detail = errEvent?.message ?? errEvent?.type ?? "ws-error";
|
|
235
|
+
for (const h of this.errorHandlers) {
|
|
236
|
+
try {
|
|
237
|
+
h("ws", this.activeDid, detail);
|
|
238
|
+
}
|
|
239
|
+
catch { /* swallow handler errors */ }
|
|
240
|
+
}
|
|
241
|
+
if (!this.connected)
|
|
242
|
+
reject(new Error(`WebSocket error: ${e}`));
|
|
243
|
+
};
|
|
244
|
+
this.ws.onmessage = (event) => {
|
|
245
|
+
// Guard against malformed frames (upstream #1998) — combines with
|
|
246
|
+
// our additive event-hook handler.
|
|
247
|
+
let frame;
|
|
248
|
+
try {
|
|
249
|
+
frame = JSON.parse(String(event.data));
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
console.warn(`MeshClient: dropping malformed frame (JSON parse): ${err}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.handleFrame(frame).catch((err) => {
|
|
256
|
+
console.warn(`MeshClient: handler error for frame type=${String(frame.type)}: ${err}`);
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
this.ws.onclose = (event) => {
|
|
260
|
+
const wasConnected = this.connected;
|
|
261
|
+
this.connected = false;
|
|
262
|
+
if (wasConnected) {
|
|
263
|
+
// Distinguish client-initiated disconnect (1000 Normal Closure) from server / network drops.
|
|
264
|
+
const code = event?.code;
|
|
265
|
+
const isClientInitiated = code === 1000 || this.clientInitiatedClose;
|
|
266
|
+
// The relay sends WS_CLOSE_SESSION_REPLACED when another connection
|
|
267
|
+
// authenticated for this same DID and displaced this socket. It is
|
|
268
|
+
// genuinely server-initiated (so it is NOT reported as "client"),
|
|
269
|
+
// but it must not trigger auto-reconnect: reconnecting would displace
|
|
270
|
+
// the socket that just replaced us and the two would evict each other
|
|
271
|
+
// in a loop. Surface it through the error channel instead so the host
|
|
272
|
+
// can act — if this agent did not initiate a reconnect, then another
|
|
273
|
+
// party successfully authenticated as this DID.
|
|
274
|
+
const isSessionReplaced = code === exports.WS_CLOSE_SESSION_REPLACED;
|
|
275
|
+
const reason = isClientInitiated ? "client" : "server";
|
|
276
|
+
for (const h of this.disconnectHandlers) {
|
|
277
|
+
try {
|
|
278
|
+
h(reason, code);
|
|
279
|
+
}
|
|
280
|
+
catch { /* swallow handler errors */ }
|
|
281
|
+
}
|
|
282
|
+
if (isSessionReplaced) {
|
|
283
|
+
for (const h of this.errorHandlers) {
|
|
284
|
+
try {
|
|
285
|
+
h("ws", this.activeDid, "session replaced: another connection authenticated for this DID; " +
|
|
286
|
+
"not reconnecting to avoid an eviction loop");
|
|
287
|
+
}
|
|
288
|
+
catch { /* swallow handler errors */ }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Auto-reconnect on non-client closures (network drops, relay restart).
|
|
292
|
+
// Mirrors vendored agentmesh-sdk patch #9: never give up by default,
|
|
293
|
+
// exponential backoff capped at 60s. Caller can opt out via
|
|
294
|
+
// autoReconnect: false in MeshClientOptions.
|
|
295
|
+
if (!isClientInitiated && !isSessionReplaced && this.autoReconnect) {
|
|
296
|
+
this.scheduleReconnect();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
this.clientInitiatedClose = false;
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
// Successful connect — reset reconnect counter.
|
|
303
|
+
this.reconnectAttempts = 0;
|
|
304
|
+
// Auto-register the agent in the registry on first successful connect.
|
|
305
|
+
// Idempotent: a re-connect after a relay restart skips this. The
|
|
306
|
+
// registry POST is idempotent on its end too (409 = already present).
|
|
307
|
+
if (this.autoRegister && this.registry && !this.registered) {
|
|
308
|
+
await this.registerSelf();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Publish this agent in the registry and upload an X3DH pre-key bundle.
|
|
313
|
+
*
|
|
314
|
+
* - Generates a signed pre-key and `oneTimePrekeyCount` one-time
|
|
315
|
+
* pre-keys via `keyManager.generateSignedPreKey()` /
|
|
316
|
+
* `generateOneTimePreKeys()`.
|
|
317
|
+
* - Capabilities are `[displayName?, ...options.capabilities]` so peers
|
|
318
|
+
* can find this agent via `registry.discover(displayName)`.
|
|
319
|
+
* - Throws RegistryError on transport / 4xx (other than 409) / 5xx
|
|
320
|
+
* failure. Callers that want best-effort registration should catch.
|
|
321
|
+
*
|
|
322
|
+
* Safe to call directly when `autoRegister: false` was used.
|
|
323
|
+
*/
|
|
324
|
+
async registerSelf() {
|
|
325
|
+
if (!this.registry) {
|
|
326
|
+
throw new Error("MeshClient.registerSelf: no registry configured");
|
|
327
|
+
}
|
|
328
|
+
const km = this.options.keyManager;
|
|
329
|
+
// identityKey is the long-term X25519 public key derived from the
|
|
330
|
+
// Ed25519 signing key in the X3DHKeyManager constructor.
|
|
331
|
+
// identityKeyEd is the Ed25519 public key — peers MUST receive it
|
|
332
|
+
// to verify the signed pre-key signature (see x3dh.ts verifyBundle).
|
|
333
|
+
const identityKey = km.identityKey.publicKey;
|
|
334
|
+
const identityKeyEd = km.identityKeyEd;
|
|
335
|
+
const signedPreKey = km.generateSignedPreKey();
|
|
336
|
+
const oneTimePreKeys = km.generateOneTimePreKeys(this.oneTimePrekeyCount);
|
|
337
|
+
// Capabilities: include displayName so name-based discover() works.
|
|
338
|
+
const caps = [];
|
|
339
|
+
const dn = this.options.displayName;
|
|
340
|
+
if (dn)
|
|
341
|
+
caps.push(dn);
|
|
342
|
+
for (const c of this.options.capabilities ?? []) {
|
|
343
|
+
if (c && !caps.includes(c))
|
|
344
|
+
caps.push(c);
|
|
345
|
+
}
|
|
346
|
+
const metadata = { ...(this.options.registrationMetadata ?? {}) };
|
|
347
|
+
if (dn && metadata.display_name === undefined)
|
|
348
|
+
metadata.display_name = dn;
|
|
349
|
+
// POP-aware registration (AGT registry built from main since 2026-05-23,
|
|
350
|
+
// PR #2533). The registry verifies Ed25519(public_key_b64 || timestamp)
|
|
351
|
+
// against the supplied `public_key` (which MUST be the Ed25519 key,
|
|
352
|
+
// not the X25519 key — the server uses pynacl `VerifyKey` on it).
|
|
353
|
+
// Server then derives the canonical DID as
|
|
354
|
+
// `did:mesh:<sha256(public_key)[:32]>` and returns it. We adopt that
|
|
355
|
+
// DID as the active DID so subsequent registry lookups + relay
|
|
356
|
+
// `connect.from` agree with the server's view.
|
|
357
|
+
const popSigner = { sign: (m) => km.signMessage(m) };
|
|
358
|
+
const registerResult = await this.registry.register(this.activeDid, // ignored by POP-aware registries; kept for legacy back-compat
|
|
359
|
+
identityKeyEd, // POP path uses the Ed25519 public; legacy path tolerates
|
|
360
|
+
// either key since it only forwards to peers verbatim.
|
|
361
|
+
caps, metadata, popSigner);
|
|
362
|
+
// Adopt the canonical server DID. Legacy registries return the
|
|
363
|
+
// caller-supplied DID, so this is a no-op when registering against an
|
|
364
|
+
// older deployment.
|
|
365
|
+
if (registerResult.did && registerResult.did !== this.activeDid) {
|
|
366
|
+
this.activeDid = registerResult.did;
|
|
367
|
+
}
|
|
368
|
+
// Prekey upload must use the canonical DID (the registry indexes
|
|
369
|
+
// prekeys by the DID it derived, not the one the SDK started with).
|
|
370
|
+
await this.registry.uploadPrekeys(this.activeDid, identityKey, identityKeyEd, signedPreKey, oneTimePreKeys);
|
|
371
|
+
this.registered = true;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Discover peers advertising a given capability. Returns [] if no
|
|
375
|
+
* registry is configured.
|
|
376
|
+
*/
|
|
377
|
+
async discover(capability, limit = 50) {
|
|
378
|
+
if (!this.registry)
|
|
379
|
+
return [];
|
|
380
|
+
return this.registry.discover(capability, limit);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Convenience: fetch a peer's pre-key bundle from the registry, then
|
|
384
|
+
* call `establishSession`. Throws if no registry is configured or no
|
|
385
|
+
* bundle is published for `peerId`.
|
|
386
|
+
*/
|
|
387
|
+
async establishSessionWithPeer(peerId) {
|
|
388
|
+
const existing = this.sessions.get(peerId);
|
|
389
|
+
if (existing)
|
|
390
|
+
return existing;
|
|
391
|
+
if (this.isPlaintextPeer(peerId)) {
|
|
392
|
+
return this.establishSession(peerId, {});
|
|
393
|
+
}
|
|
394
|
+
if (!this.registry) {
|
|
395
|
+
throw new Error("MeshClient.establishSessionWithPeer: no registry configured");
|
|
396
|
+
}
|
|
397
|
+
const bundle = await this.registry.fetchPrekeys(peerId);
|
|
398
|
+
if (!bundle) {
|
|
399
|
+
throw new Error(`MeshClient.establishSessionWithPeer: no prekey bundle for ${peerId}`);
|
|
400
|
+
}
|
|
401
|
+
return this.establishSession(peerId, bundle);
|
|
402
|
+
}
|
|
403
|
+
scheduleReconnect() {
|
|
404
|
+
if (this.reconnectTimer)
|
|
405
|
+
return; // already scheduled
|
|
406
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
407
|
+
for (const h of this.errorHandlers) {
|
|
408
|
+
try {
|
|
409
|
+
h("ws", this.activeDid, `auto-reconnect gave up after ${this.reconnectAttempts} attempts`);
|
|
410
|
+
}
|
|
411
|
+
catch { /* swallow */ }
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const exp = Math.min(this.reconnectBaseDelayMs * 2 ** this.reconnectAttempts, this.reconnectMaxDelayMs);
|
|
416
|
+
// Light jitter (±20%) to avoid thundering-herd reconnects across many sandboxes.
|
|
417
|
+
const jitter = exp * (0.8 + Math.random() * 0.4);
|
|
418
|
+
this.reconnectAttempts++;
|
|
419
|
+
this.reconnectTimer = setTimeout(() => {
|
|
420
|
+
this.reconnectTimer = null;
|
|
421
|
+
void this.reconnect().catch((err) => {
|
|
422
|
+
for (const h of this.errorHandlers) {
|
|
423
|
+
try {
|
|
424
|
+
h("ws", this.activeDid, `reconnect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
425
|
+
}
|
|
426
|
+
catch { /* swallow */ }
|
|
427
|
+
}
|
|
428
|
+
// Schedule next attempt — onclose may not fire if connect() rejected before ws.onopen.
|
|
429
|
+
this.scheduleReconnect();
|
|
430
|
+
});
|
|
431
|
+
}, Math.round(jitter));
|
|
432
|
+
}
|
|
433
|
+
async disconnect() {
|
|
434
|
+
// Cancel any pending reconnect — caller asked to stay disconnected.
|
|
435
|
+
if (this.reconnectTimer) {
|
|
436
|
+
clearTimeout(this.reconnectTimer);
|
|
437
|
+
this.reconnectTimer = null;
|
|
438
|
+
}
|
|
439
|
+
this.reconnectAttempts = 0;
|
|
440
|
+
// Drop any buffered pre-KNOCK frames + their eviction timers.
|
|
441
|
+
for (const peer of [...this.preKnockBuffer.keys()]) {
|
|
442
|
+
this.dropPreKnockBuffer(peer);
|
|
443
|
+
}
|
|
444
|
+
if (!this.connected || !this.ws)
|
|
445
|
+
return;
|
|
446
|
+
this.clientInitiatedClose = true;
|
|
447
|
+
this.sendFrame({ v: 1, type: "disconnect", from: this.activeDid });
|
|
448
|
+
this.ws.close(1000, "client disconnect");
|
|
449
|
+
this.connected = false;
|
|
450
|
+
this.ws = null;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Disconnect and reconnect to the relay.
|
|
454
|
+
*
|
|
455
|
+
* Resets the WebSocket connection and sends a fresh connect +
|
|
456
|
+
* fetch_pending sequence, triggering inbox replay for any messages
|
|
457
|
+
* queued while offline.
|
|
458
|
+
*/
|
|
459
|
+
async reconnect() {
|
|
460
|
+
this.connected = false;
|
|
461
|
+
if (this.ws) {
|
|
462
|
+
try {
|
|
463
|
+
this.ws.close();
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
/* ignore close errors */
|
|
467
|
+
}
|
|
468
|
+
this.ws = null;
|
|
469
|
+
}
|
|
470
|
+
await this.connect();
|
|
471
|
+
}
|
|
472
|
+
get isConnected() {
|
|
473
|
+
return this.connected && this.ws !== null;
|
|
474
|
+
}
|
|
475
|
+
// ── Sending ─────────────────────────────────────────────────────
|
|
476
|
+
async send(peerId, payload) {
|
|
477
|
+
if (!this.isConnected)
|
|
478
|
+
throw new Error("Not connected to relay");
|
|
479
|
+
const messageId = crypto.randomUUID();
|
|
480
|
+
if (this.isPlaintextPeer(peerId)) {
|
|
481
|
+
// Legacy plaintext path — no encryption
|
|
482
|
+
this.sendFrame({
|
|
483
|
+
v: 1,
|
|
484
|
+
type: "message",
|
|
485
|
+
from: this.activeDid,
|
|
486
|
+
to: peerId,
|
|
487
|
+
id: messageId,
|
|
488
|
+
ts: new Date().toISOString(),
|
|
489
|
+
ciphertext: btoa(JSON.stringify(payload)),
|
|
490
|
+
plaintext: true,
|
|
491
|
+
});
|
|
492
|
+
this.incrementSessionCount(peerId, true);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Encrypted path
|
|
496
|
+
let session = this.sessions.get(peerId);
|
|
497
|
+
if (!session || !session.channel) {
|
|
498
|
+
throw new Error(`No encrypted session with ${peerId}. Call establishSession() first.`);
|
|
499
|
+
}
|
|
500
|
+
const encrypted = session.channel.send(new TextEncoder().encode(JSON.stringify(payload)));
|
|
501
|
+
this.sendFrame({
|
|
502
|
+
v: 1,
|
|
503
|
+
type: "message",
|
|
504
|
+
from: this.activeDid,
|
|
505
|
+
to: peerId,
|
|
506
|
+
id: messageId,
|
|
507
|
+
ts: new Date().toISOString(),
|
|
508
|
+
header: {
|
|
509
|
+
dh: this.uint8ToBase64(encrypted.header.dhPublicKey),
|
|
510
|
+
pn: encrypted.header.previousChainLength,
|
|
511
|
+
n: encrypted.header.messageNumber,
|
|
512
|
+
},
|
|
513
|
+
ciphertext: this.uint8ToBase64(encrypted.ciphertext),
|
|
514
|
+
});
|
|
515
|
+
session.messageCount++;
|
|
516
|
+
}
|
|
517
|
+
// ── Session establishment ───────────────────────────────────────
|
|
518
|
+
async establishSession(peerId, peerBundle) {
|
|
519
|
+
// Check for existing session
|
|
520
|
+
const existing = this.sessions.get(peerId);
|
|
521
|
+
if (existing)
|
|
522
|
+
return existing;
|
|
523
|
+
if (this.isPlaintextPeer(peerId)) {
|
|
524
|
+
const session = {
|
|
525
|
+
peerId,
|
|
526
|
+
channel: null,
|
|
527
|
+
isPlaintext: true,
|
|
528
|
+
createdAt: new Date(),
|
|
529
|
+
messageCount: 0,
|
|
530
|
+
};
|
|
531
|
+
this.sessions.set(peerId, session);
|
|
532
|
+
return session;
|
|
533
|
+
}
|
|
534
|
+
// Send KNOCK
|
|
535
|
+
const knockId = crypto.randomUUID();
|
|
536
|
+
// X3DH + SecureChannel — compute establishment FIRST so we can embed it
|
|
537
|
+
// in the KNOCK frame. This lets the receiver auto-bootstrap the responder
|
|
538
|
+
// session before any ciphertext arrives, eliminating the
|
|
539
|
+
// "No encrypted session" race that vendored agentmesh-sdk patch #4b
|
|
540
|
+
// worked around. Backwards-compatible: receivers that don't understand
|
|
541
|
+
// the `establishment` field fall back to manual acceptSession() calls.
|
|
542
|
+
const [channel, establishment] = channel_1.SecureChannel.createSender(this.options.keyManager, peerBundle, new TextEncoder().encode(`${this.activeDid}|${peerId}`));
|
|
543
|
+
this.sendFrame({
|
|
544
|
+
v: 1,
|
|
545
|
+
type: "knock",
|
|
546
|
+
from: this.activeDid,
|
|
547
|
+
to: peerId,
|
|
548
|
+
id: knockId,
|
|
549
|
+
ts: new Date().toISOString(),
|
|
550
|
+
intent: { action: "establish_session" },
|
|
551
|
+
establishment: this.serializeEstablishment(establishment),
|
|
552
|
+
});
|
|
553
|
+
const session = {
|
|
554
|
+
peerId,
|
|
555
|
+
channel,
|
|
556
|
+
isPlaintext: false,
|
|
557
|
+
createdAt: new Date(),
|
|
558
|
+
messageCount: 0,
|
|
559
|
+
};
|
|
560
|
+
this.sessions.set(peerId, session);
|
|
561
|
+
return session;
|
|
562
|
+
}
|
|
563
|
+
acceptSession(peerId, establishment) {
|
|
564
|
+
const channel = channel_1.SecureChannel.createReceiver(this.options.keyManager, establishment, new TextEncoder().encode(`${peerId}|${this.activeDid}`));
|
|
565
|
+
const session = {
|
|
566
|
+
peerId,
|
|
567
|
+
channel,
|
|
568
|
+
isPlaintext: false,
|
|
569
|
+
createdAt: new Date(),
|
|
570
|
+
messageCount: 0,
|
|
571
|
+
};
|
|
572
|
+
this.sessions.set(peerId, session);
|
|
573
|
+
this.knockAccepted.add(peerId);
|
|
574
|
+
return session;
|
|
575
|
+
}
|
|
576
|
+
getSession(peerId) {
|
|
577
|
+
return this.sessions.get(peerId);
|
|
578
|
+
}
|
|
579
|
+
closeSession(peerId) {
|
|
580
|
+
const session = this.sessions.get(peerId);
|
|
581
|
+
if (!session)
|
|
582
|
+
return false;
|
|
583
|
+
if (session.channel)
|
|
584
|
+
session.channel.close();
|
|
585
|
+
this.sessions.delete(peerId);
|
|
586
|
+
this.knockAccepted.delete(peerId);
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
// ── Handlers ────────────────────────────────────────────────────
|
|
590
|
+
onMessage(handler) {
|
|
591
|
+
this.messageHandlers.push(handler);
|
|
592
|
+
}
|
|
593
|
+
onKnock(handler) {
|
|
594
|
+
this.knockHandlers.push(handler);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Register a callback for transport-level errors.
|
|
598
|
+
*
|
|
599
|
+
* Fires for: WebSocket errors (handshake, mid-stream), decrypt failures,
|
|
600
|
+
* KNOCK protocol errors, frame validation, and `session_desync` events
|
|
601
|
+
* (recoverable ratchet drift — caller should re-establishSession to that
|
|
602
|
+
* peer; differs from "decrypt" which means no session existed at all).
|
|
603
|
+
* Multiple handlers may be registered; each is invoked in registration
|
|
604
|
+
* order. Handler exceptions are swallowed so one buggy observer cannot
|
|
605
|
+
* break the others.
|
|
606
|
+
*/
|
|
607
|
+
onError(handler) {
|
|
608
|
+
this.errorHandlers.push(handler);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Register a callback for transport disconnect events.
|
|
612
|
+
*
|
|
613
|
+
* `reason` is `"client"` for caller-initiated `disconnect()`, `"server"`
|
|
614
|
+
* for relay-side closes (network drop, relay restart), and `"ws-error"`
|
|
615
|
+
* when an error event fires on an already-connected socket.
|
|
616
|
+
*/
|
|
617
|
+
onDisconnect(handler) {
|
|
618
|
+
this.disconnectHandlers.push(handler);
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Register a callback that fires the first time we successfully decrypt
|
|
622
|
+
* a message from a given peer (i.e. the X3DH+Double-Ratchet session
|
|
623
|
+
* with that peer is fully end-to-end verified).
|
|
624
|
+
*
|
|
625
|
+
* `isFirstPeer` is `true` only for the very first verified peer in the
|
|
626
|
+
* client's lifetime; subsequent peers fire with `false`. This lets
|
|
627
|
+
* orchestrators print "mesh online" once and "+ peer X verified" for
|
|
628
|
+
* the rest.
|
|
629
|
+
*/
|
|
630
|
+
onE2EVerified(handler) {
|
|
631
|
+
this.e2eVerifiedHandlers.push(handler);
|
|
632
|
+
}
|
|
633
|
+
// ── Heartbeat ───────────────────────────────────────────────────
|
|
634
|
+
sendHeartbeat() {
|
|
635
|
+
if (!this.isConnected)
|
|
636
|
+
return;
|
|
637
|
+
this.sendFrame({
|
|
638
|
+
v: 1,
|
|
639
|
+
type: "heartbeat",
|
|
640
|
+
from: this.activeDid,
|
|
641
|
+
ts: new Date().toISOString(),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// ── Frame handling ──────────────────────────────────────────────
|
|
645
|
+
async handleFrame(frame) {
|
|
646
|
+
const type = frame.type;
|
|
647
|
+
const from = frame.from;
|
|
648
|
+
if (type === "message") {
|
|
649
|
+
await this.handleMessage(frame);
|
|
650
|
+
}
|
|
651
|
+
else if (type === "knock") {
|
|
652
|
+
await this.handleKnock(frame);
|
|
653
|
+
}
|
|
654
|
+
else if (type === "knock_accept") {
|
|
655
|
+
this.handleKnockAccept(frame);
|
|
656
|
+
}
|
|
657
|
+
else if (type === "knock_reject") {
|
|
658
|
+
this.handleKnockReject(frame);
|
|
659
|
+
}
|
|
660
|
+
else if (type === "ack") {
|
|
661
|
+
// ACK processed — nothing to do
|
|
662
|
+
}
|
|
663
|
+
else if (type === "pending_messages") {
|
|
664
|
+
await this.handlePendingMessages(frame);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async handleMessage(frame) {
|
|
668
|
+
const from = frame.from;
|
|
669
|
+
// Check KNOCK pending queue — wait for resolution if KNOCK is in-flight
|
|
670
|
+
if (!this.knockAccepted.has(from) && !this.isPlaintextPeer(from)) {
|
|
671
|
+
const pending = this.knockPending.get(from);
|
|
672
|
+
if (pending) {
|
|
673
|
+
// Wait for KNOCK resolution
|
|
674
|
+
const accepted = await new Promise((resolve) => {
|
|
675
|
+
const originalResolve = pending.resolve;
|
|
676
|
+
pending.resolve = (val) => {
|
|
677
|
+
originalResolve(val);
|
|
678
|
+
resolve(val);
|
|
679
|
+
};
|
|
680
|
+
});
|
|
681
|
+
if (!accepted)
|
|
682
|
+
return; // KNOCK rejected — drop message
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
let payload;
|
|
686
|
+
let isPlaintext = false;
|
|
687
|
+
// Security hardening: whether an inbound message is treated as legacy
|
|
688
|
+
// plaintext is decided solely from the receiver's own operator
|
|
689
|
+
// configuration (`plaintextPeers`, via isPlaintextPeer(from)). The wire
|
|
690
|
+
// frame also carries a `plaintext` boolean, but it is sender-controlled and
|
|
691
|
+
// must not select this branch — trusting it would let the sender skip
|
|
692
|
+
// X3DH / Double Ratchet / AEAD verification and be attributed an arbitrary
|
|
693
|
+
// `from` DID. Gate on isPlaintextPeer only; a peer that is not explicitly
|
|
694
|
+
// allow-listed for plaintext always takes the encrypted path below and is
|
|
695
|
+
// dropped if it cannot be cryptographically authenticated.
|
|
696
|
+
if (this.isPlaintextPeer(from) &&
|
|
697
|
+
!this.sessions.get(from)?.channel &&
|
|
698
|
+
!this.e2eVerifiedSet.has(from)) {
|
|
699
|
+
// Legacy plaintext — taken ONLY for an operator-allow-listed peer that has
|
|
700
|
+
// no negotiated encrypted session AND has never been E2E-verified.
|
|
701
|
+
//
|
|
702
|
+
// The `e2eVerifiedSet` term is a one-way latch and is load-bearing. A live
|
|
703
|
+
// `session.channel` is NOT sufficient on its own, because several paths
|
|
704
|
+
// delete the session and would otherwise re-open this gate for a peer that
|
|
705
|
+
// had already been cryptographically verified:
|
|
706
|
+
// * the Gap-G3 ratchet-desync handler below calls closeSession() after a
|
|
707
|
+
// single decrypt failure — a malicious relay can force that with one
|
|
708
|
+
// garbage-ciphertext frame and then downgrade with the next frame;
|
|
709
|
+
// * handleKnockReject() calls closeSession() on an unauthenticated
|
|
710
|
+
// `knock_reject` frame — a one-frame variant of the same attack;
|
|
711
|
+
// * any future teardown path would silently inherit the same weakness.
|
|
712
|
+
// Latching on e2eVerifiedSet (which is only ever added to, never cleared)
|
|
713
|
+
// makes the guarantee teardown-path-agnostic: once a peer has produced a
|
|
714
|
+
// successfully decrypted frame, it can never again be handled as plaintext
|
|
715
|
+
// for the lifetime of this client.
|
|
716
|
+
//
|
|
717
|
+
// Once a live channel exists for the peer we deliberately fall through to
|
|
718
|
+
// the encrypted path instead of handling the frame as plaintext: a genuine
|
|
719
|
+
// encrypted frame is decrypted via the ratchet, while a plaintext /
|
|
720
|
+
// headerless downgrade frame fails closed on the missing-ratchet-header
|
|
721
|
+
// check below. This keeps downgrade protection while no longer
|
|
722
|
+
// black-holing legitimate encrypted traffic from a peer that is also
|
|
723
|
+
// allow-listed for plaintext.
|
|
724
|
+
payload = JSON.parse(atob(frame.ciphertext));
|
|
725
|
+
isPlaintext = true;
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
// Encrypted
|
|
729
|
+
const header = frame.header;
|
|
730
|
+
if (!header || typeof header.dh !== "string") {
|
|
731
|
+
// Security hardening: an encrypted `message` frame must carry a ratchet
|
|
732
|
+
// header. Validate this BEFORE the pre-KNOCK buffering path below so a
|
|
733
|
+
// malformed / headerless frame — which can never be decrypted — is
|
|
734
|
+
// dropped immediately, instead of occupying pre-KNOCK buffer capacity
|
|
735
|
+
// until TTL eviction and only failing closed when the buffer is drained.
|
|
736
|
+
for (const h of this.errorHandlers) {
|
|
737
|
+
try {
|
|
738
|
+
h("decrypt", from, "encrypted message missing ratchet header — dropping");
|
|
739
|
+
}
|
|
740
|
+
catch { /* swallow */ }
|
|
741
|
+
}
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const session = this.sessions.get(from);
|
|
745
|
+
if (!session?.channel) {
|
|
746
|
+
// Gap-G4 (vendored agentmesh-sdk patch #16): pre-KNOCK message buffer.
|
|
747
|
+
// The relay does not guarantee inter-frame ordering — an encrypted
|
|
748
|
+
// message can land before its KNOCK is processed. Drop-on-floor was
|
|
749
|
+
// the upstream behaviour; instead, buffer the raw frame here and
|
|
750
|
+
// drain it from handleKnock() once the KNOCK is accepted. Capped at
|
|
751
|
+
// preKnockBufferSize entries per peer with preKnockBufferTtlMs TTL.
|
|
752
|
+
if (this.preKnockBufferSize > 0 && !this.isPlaintextPeer(from)) {
|
|
753
|
+
this.bufferPreKnockFrame(from, frame);
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
for (const h of this.errorHandlers) {
|
|
757
|
+
try {
|
|
758
|
+
h("decrypt", from, "no session for encrypted message — dropping");
|
|
759
|
+
}
|
|
760
|
+
catch { /* swallow */ }
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const encrypted = {
|
|
766
|
+
header: {
|
|
767
|
+
dhPublicKey: this.base64ToUint8(header.dh),
|
|
768
|
+
previousChainLength: header.pn,
|
|
769
|
+
messageNumber: header.n,
|
|
770
|
+
},
|
|
771
|
+
ciphertext: this.base64ToUint8(frame.ciphertext),
|
|
772
|
+
};
|
|
773
|
+
let plaintext;
|
|
774
|
+
try {
|
|
775
|
+
plaintext = session.channel.receive(encrypted);
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
// Gap-G3 (vendored agentmesh-sdk patch #13): on decrypt failure inside
|
|
779
|
+
// an existing session, the ratchet is desynchronised — every
|
|
780
|
+
// subsequent inbound frame will fail the same way. Tear down the
|
|
781
|
+
// broken session so the next establishSession() to this peer runs a
|
|
782
|
+
// fresh X3DH + KNOCK round, and surface a dedicated "session_desync"
|
|
783
|
+
// error kind so callers can distinguish recoverable ratchet drift
|
|
784
|
+
// (re-establish & retry) from genuine tampering (drop & alert).
|
|
785
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
786
|
+
this.closeSession(from);
|
|
787
|
+
this.knockAccepted.delete(from);
|
|
788
|
+
for (const h of this.errorHandlers) {
|
|
789
|
+
try {
|
|
790
|
+
h("session_desync", from, detail);
|
|
791
|
+
}
|
|
792
|
+
catch { /* swallow */ }
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
797
|
+
session.messageCount++;
|
|
798
|
+
// First successfully-decrypted message from this peer means E2E
|
|
799
|
+
// is end-to-end verified (KNOCK + X3DH + Double Ratchet all worked).
|
|
800
|
+
// Surface to observers exactly once per peer per process lifetime.
|
|
801
|
+
if (!this.e2eVerifiedSet.has(from)) {
|
|
802
|
+
this.e2eVerifiedSet.add(from);
|
|
803
|
+
const isFirstPeer = this.e2eVerifiedSet.size === 1;
|
|
804
|
+
for (const h of this.e2eVerifiedHandlers) {
|
|
805
|
+
try {
|
|
806
|
+
h(from, isFirstPeer);
|
|
807
|
+
}
|
|
808
|
+
catch { /* swallow handler errors */ }
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// Send ACK
|
|
813
|
+
this.sendFrame({ v: 1, type: "ack", id: frame.id });
|
|
814
|
+
// Notify handlers
|
|
815
|
+
for (const handler of this.messageHandlers) {
|
|
816
|
+
try {
|
|
817
|
+
handler(from, payload, isPlaintext);
|
|
818
|
+
}
|
|
819
|
+
catch { /* swallow handler errors */ }
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Handle a batch of pending messages replayed by the relay on reconnect.
|
|
824
|
+
*
|
|
825
|
+
* The relay may respond to a fetch_pending frame with a
|
|
826
|
+
* pending_messages frame containing an array of queued messages.
|
|
827
|
+
* Each message is dispatched through the standard handleMessage path.
|
|
828
|
+
*/
|
|
829
|
+
async handlePendingMessages(frame) {
|
|
830
|
+
// Typed as unknown[] rather than Record<string, unknown>[] because this is
|
|
831
|
+
// relay-supplied JSON: entries can legally be null or primitives, and the
|
|
832
|
+
// stricter type would be a lie that hides the validation below.
|
|
833
|
+
const messages = frame.messages;
|
|
834
|
+
if (!Array.isArray(messages))
|
|
835
|
+
return;
|
|
836
|
+
for (const msg of messages) {
|
|
837
|
+
const isFrameShaped = typeof msg === "object" && msg !== null && !Array.isArray(msg);
|
|
838
|
+
// Extract the peer *before* the try and without dereferencing a
|
|
839
|
+
// possibly-null entry. Doing it inside the catch would throw on a null
|
|
840
|
+
// entry, and that throw would be absorbed by the handler guard below,
|
|
841
|
+
// losing the drop report entirely.
|
|
842
|
+
const rawFrom = isFrameShaped ? msg.from : undefined;
|
|
843
|
+
const peer = typeof rawFrom === "string" ? rawFrom : "";
|
|
844
|
+
// Isolate each queued message: the batch is relay-supplied, so a single
|
|
845
|
+
// malformed entry (e.g. ciphertext that fails JSON.parse) must not abort
|
|
846
|
+
// the loop and silently discard the remaining pending mail. Surface the
|
|
847
|
+
// failure and continue draining.
|
|
848
|
+
try {
|
|
849
|
+
if (!isFrameShaped) {
|
|
850
|
+
throw new Error(`expected a frame object, got ${msg === null ? "null" : typeof msg}`);
|
|
851
|
+
}
|
|
852
|
+
await this.handleMessage(msg);
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
856
|
+
for (const h of this.errorHandlers) {
|
|
857
|
+
try {
|
|
858
|
+
h("frame", peer, `pending message dropped: ${detail}`);
|
|
859
|
+
}
|
|
860
|
+
catch { /* swallow */ }
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async handleKnock(frame) {
|
|
866
|
+
const from = frame.from;
|
|
867
|
+
const intent = frame.intent;
|
|
868
|
+
// Register pending entry so concurrent handleMessage calls wait for
|
|
869
|
+
// the verdict. handleMessage may wrap `resolve` to add its own waiter;
|
|
870
|
+
// we look up the (possibly-wrapped) function when we resolve below.
|
|
871
|
+
//
|
|
872
|
+
// The timer is the abort path: if no verdict arrives within
|
|
873
|
+
// knockTimeout, resolve waiters to false and clear the entry. A
|
|
874
|
+
// `timedOut` flag prevents a slow handler from sending knock_accept
|
|
875
|
+
// after the timer has already told waiters the KNOCK was rejected.
|
|
876
|
+
let timedOut = false;
|
|
877
|
+
const timer = setTimeout(() => {
|
|
878
|
+
timedOut = true;
|
|
879
|
+
const entry = this.knockPending.get(from);
|
|
880
|
+
if (entry) {
|
|
881
|
+
this.knockPending.delete(from);
|
|
882
|
+
entry.resolve(false);
|
|
883
|
+
}
|
|
884
|
+
}, this.knockTimeout);
|
|
885
|
+
this.knockPending.set(from, { resolve: () => { }, timer });
|
|
886
|
+
// Evaluate via registered handlers. Use try/finally so the timer is
|
|
887
|
+
// cleared and the pending entry resolved even if a handler throws —
|
|
888
|
+
// otherwise the entry would leak and a re-KNOCK from the same peer
|
|
889
|
+
// would race against stale state.
|
|
890
|
+
let accepted = true;
|
|
891
|
+
try {
|
|
892
|
+
for (const handler of this.knockHandlers) {
|
|
893
|
+
if (!(await handler(from, intent))) {
|
|
894
|
+
accepted = false;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
clearTimeout(timer);
|
|
901
|
+
if (!timedOut) {
|
|
902
|
+
const entry = this.knockPending.get(from);
|
|
903
|
+
if (entry) {
|
|
904
|
+
this.knockPending.delete(from);
|
|
905
|
+
entry.resolve(accepted);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// If the timer beat the handler eval, waiters were already told
|
|
910
|
+
// "rejected"; honor that decision when responding to the relay.
|
|
911
|
+
if (timedOut)
|
|
912
|
+
accepted = false;
|
|
913
|
+
if (accepted) {
|
|
914
|
+
// Auto-bootstrap responder session if establishment data was embedded
|
|
915
|
+
// in the knock. Mirrors vendored agentmesh-sdk patch #4b — receiver no
|
|
916
|
+
// longer needs to call acceptSession() manually before the first
|
|
917
|
+
// encrypted message arrives.
|
|
918
|
+
const est = frame.establishment;
|
|
919
|
+
if (est && !this.sessions.has(from)) {
|
|
920
|
+
try {
|
|
921
|
+
const establishment = this.deserializeEstablishment(est);
|
|
922
|
+
this.acceptSession(from, establishment);
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
for (const h of this.errorHandlers) {
|
|
926
|
+
try {
|
|
927
|
+
h("knock", from, `failed to bootstrap responder from KNOCK: ${err instanceof Error ? err.message : String(err)}`);
|
|
928
|
+
}
|
|
929
|
+
catch { /* swallow */ }
|
|
930
|
+
}
|
|
931
|
+
accepted = false;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// Always record acceptance so the encrypted-message gate stops
|
|
935
|
+
// waiting/buffering. (`acceptSession` also sets this, but keep the
|
|
936
|
+
// legacy-peer path covered too.)
|
|
937
|
+
if (accepted)
|
|
938
|
+
this.knockAccepted.add(from);
|
|
939
|
+
}
|
|
940
|
+
if (accepted) {
|
|
941
|
+
this.sendFrame({
|
|
942
|
+
v: 1,
|
|
943
|
+
type: "knock_accept",
|
|
944
|
+
from: this.activeDid,
|
|
945
|
+
to: from,
|
|
946
|
+
id: crypto.randomUUID(),
|
|
947
|
+
knock_id: frame.id,
|
|
948
|
+
ts: new Date().toISOString(),
|
|
949
|
+
});
|
|
950
|
+
// Gap-G4: drain any encrypted frames that arrived for this peer
|
|
951
|
+
// before its KNOCK was processed. The session is now established
|
|
952
|
+
// (via auto-bootstrap above or a prior acceptSession call), so the
|
|
953
|
+
// buffered frames can be replayed through the normal handleMessage
|
|
954
|
+
// path and decrypted against the fresh session.
|
|
955
|
+
await this.drainPreKnockBuffer(from);
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
this.sendFrame({
|
|
959
|
+
v: 1,
|
|
960
|
+
type: "knock_reject",
|
|
961
|
+
from: this.activeDid,
|
|
962
|
+
to: from,
|
|
963
|
+
id: crypto.randomUUID(),
|
|
964
|
+
knock_id: frame.id,
|
|
965
|
+
reason: "policy_denied",
|
|
966
|
+
ts: new Date().toISOString(),
|
|
967
|
+
});
|
|
968
|
+
// Drop any buffered frames — KNOCK was rejected so we cannot decrypt
|
|
969
|
+
// them anyway. Avoids unbounded buffer growth for hostile peers.
|
|
970
|
+
this.dropPreKnockBuffer(from);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
handleKnockAccept(frame) {
|
|
974
|
+
const from = frame.from;
|
|
975
|
+
this.knockAccepted.add(from);
|
|
976
|
+
}
|
|
977
|
+
handleKnockReject(frame) {
|
|
978
|
+
const from = frame.from;
|
|
979
|
+
this.closeSession(from);
|
|
980
|
+
}
|
|
981
|
+
// ── Pre-KNOCK buffer (Gap-G4 / vendored patch #16) ──────────────
|
|
982
|
+
bufferPreKnockFrame(from, frame) {
|
|
983
|
+
let entries = this.preKnockBuffer.get(from);
|
|
984
|
+
if (!entries) {
|
|
985
|
+
// Enforce global peer cap before adding a new peer's buffer.
|
|
986
|
+
if (this.preKnockBuffer.size >= this.maxBufferedPeers) {
|
|
987
|
+
const oldestPeer = this.preKnockBuffer.keys().next().value;
|
|
988
|
+
this.dropPreKnockBuffer(oldestPeer);
|
|
989
|
+
for (const h of this.errorHandlers) {
|
|
990
|
+
try {
|
|
991
|
+
h("frame", oldestPeer, `pre-knock buffer evicted: global peer cap (${this.maxBufferedPeers}) reached`);
|
|
992
|
+
}
|
|
993
|
+
catch { /* swallow */ }
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
entries = [];
|
|
997
|
+
this.preKnockBuffer.set(from, entries);
|
|
998
|
+
}
|
|
999
|
+
// Cap: drop oldest when full to keep newest message (sender most likely
|
|
1000
|
+
// to retransmit nothing — newer frames carry the most recent ratchet
|
|
1001
|
+
// state and have the best chance of being decryptable).
|
|
1002
|
+
if (entries.length >= this.preKnockBufferSize) {
|
|
1003
|
+
const evicted = entries.shift();
|
|
1004
|
+
if (evicted)
|
|
1005
|
+
clearTimeout(evicted.timer);
|
|
1006
|
+
}
|
|
1007
|
+
const timer = setTimeout(() => {
|
|
1008
|
+
const list = this.preKnockBuffer.get(from);
|
|
1009
|
+
if (!list)
|
|
1010
|
+
return;
|
|
1011
|
+
const idx = list.findIndex((e) => e.frame === frame);
|
|
1012
|
+
if (idx >= 0)
|
|
1013
|
+
list.splice(idx, 1);
|
|
1014
|
+
if (list.length === 0)
|
|
1015
|
+
this.preKnockBuffer.delete(from);
|
|
1016
|
+
}, this.preKnockBufferTtlMs);
|
|
1017
|
+
entries.push({ frame, timer });
|
|
1018
|
+
}
|
|
1019
|
+
async drainPreKnockBuffer(from) {
|
|
1020
|
+
const entries = this.preKnockBuffer.get(from);
|
|
1021
|
+
if (!entries || entries.length === 0)
|
|
1022
|
+
return;
|
|
1023
|
+
this.preKnockBuffer.delete(from);
|
|
1024
|
+
for (const entry of entries) {
|
|
1025
|
+
clearTimeout(entry.timer);
|
|
1026
|
+
try {
|
|
1027
|
+
await this.handleMessage(entry.frame);
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
for (const h of this.errorHandlers) {
|
|
1031
|
+
try {
|
|
1032
|
+
h("decrypt", from, `pre-knock drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1033
|
+
}
|
|
1034
|
+
catch { /* swallow */ }
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
dropPreKnockBuffer(from) {
|
|
1040
|
+
const entries = this.preKnockBuffer.get(from);
|
|
1041
|
+
if (!entries)
|
|
1042
|
+
return;
|
|
1043
|
+
for (const entry of entries)
|
|
1044
|
+
clearTimeout(entry.timer);
|
|
1045
|
+
this.preKnockBuffer.delete(from);
|
|
1046
|
+
}
|
|
1047
|
+
// ── Establishment (de)serialization ─────────────────────────────
|
|
1048
|
+
serializeEstablishment(est) {
|
|
1049
|
+
const out = {
|
|
1050
|
+
ik: this.uint8ToBase64(est.initiatorIdentityKey),
|
|
1051
|
+
ek: this.uint8ToBase64(est.ephemeralPublicKey),
|
|
1052
|
+
};
|
|
1053
|
+
if (typeof est.usedOneTimeKeyId === "number")
|
|
1054
|
+
out.otk = est.usedOneTimeKeyId;
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
deserializeEstablishment(obj) {
|
|
1058
|
+
const ik = obj.ik;
|
|
1059
|
+
const ek = obj.ek;
|
|
1060
|
+
if (typeof ik !== "string" || typeof ek !== "string") {
|
|
1061
|
+
throw new Error("malformed establishment: missing ik/ek");
|
|
1062
|
+
}
|
|
1063
|
+
const result = {
|
|
1064
|
+
initiatorIdentityKey: this.base64ToUint8(ik),
|
|
1065
|
+
ephemeralPublicKey: this.base64ToUint8(ek),
|
|
1066
|
+
};
|
|
1067
|
+
if (typeof obj.otk === "number")
|
|
1068
|
+
result.usedOneTimeKeyId = obj.otk;
|
|
1069
|
+
return result;
|
|
1070
|
+
}
|
|
1071
|
+
// ── Utilities ───────────────────────────────────────────────────
|
|
1072
|
+
sendFrame(frame) {
|
|
1073
|
+
if (this.ws && this.connected) {
|
|
1074
|
+
this.ws.send(JSON.stringify(frame));
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
incrementSessionCount(peerId, isPlaintext) {
|
|
1078
|
+
let session = this.sessions.get(peerId);
|
|
1079
|
+
if (!session) {
|
|
1080
|
+
session = { peerId, channel: null, isPlaintext, createdAt: new Date(), messageCount: 0 };
|
|
1081
|
+
this.sessions.set(peerId, session);
|
|
1082
|
+
}
|
|
1083
|
+
session.messageCount++;
|
|
1084
|
+
}
|
|
1085
|
+
uint8ToBase64(data) {
|
|
1086
|
+
// Use Buffer in Node.js to avoid stack overflow on large payloads
|
|
1087
|
+
if (typeof Buffer !== "undefined") {
|
|
1088
|
+
return Buffer.from(data).toString("base64");
|
|
1089
|
+
}
|
|
1090
|
+
// Browser fallback — loop-based
|
|
1091
|
+
let binary = "";
|
|
1092
|
+
for (let i = 0; i < data.length; i++) {
|
|
1093
|
+
binary += String.fromCharCode(data[i]);
|
|
1094
|
+
}
|
|
1095
|
+
return btoa(binary);
|
|
1096
|
+
}
|
|
1097
|
+
base64ToUint8(b64) {
|
|
1098
|
+
if (typeof Buffer !== "undefined") {
|
|
1099
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
1100
|
+
}
|
|
1101
|
+
const binary = atob(b64);
|
|
1102
|
+
const bytes = new Uint8Array(binary.length);
|
|
1103
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1104
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1105
|
+
}
|
|
1106
|
+
return bytes;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
exports.MeshClient = MeshClient;
|
|
1110
|
+
//# sourceMappingURL=mesh-client.js.map
|