clawmatrix 0.1.23 → 0.2.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 +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2183 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +288 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +171 -92
- package/src/identity.ts +95 -0
- package/src/index.ts +433 -58
- package/src/knowledge-sync.ts +776 -207
- package/src/model-proxy.ts +144 -39
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +261 -32
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +477 -3
- package/src/web.ts +2 -2
package/src/config.ts
CHANGED
|
@@ -79,31 +79,102 @@ const ProxyModelGroupSchema = z.object({
|
|
|
79
79
|
|
|
80
80
|
const KnowledgeConfigSchema = z.object({
|
|
81
81
|
enabled: z.boolean().default(false),
|
|
82
|
+
paths: z.array(z.string()).default([]),
|
|
82
83
|
debounce: z.number().default(5000),
|
|
83
84
|
maxFileSize: z.number().default(512 * 1024),
|
|
84
85
|
}).optional();
|
|
85
86
|
|
|
87
|
+
const SentinelConfigSchema = z.object({
|
|
88
|
+
enabled: z.boolean().default(true),
|
|
89
|
+
/** Override port for sentinel listener. Default: inherits gateway's listenPort for automatic takeover. */
|
|
90
|
+
listenPort: z.number().optional(),
|
|
91
|
+
listenHost: z.string().optional(),
|
|
92
|
+
}).optional();
|
|
93
|
+
|
|
86
94
|
const WebConfigSchema = z.object({
|
|
87
95
|
enabled: z.boolean().default(false),
|
|
88
96
|
token: z.string().min(8, "web token must be at least 8 characters"),
|
|
89
97
|
}).optional();
|
|
90
98
|
|
|
99
|
+
const AcpAgentInfoSchema = z.object({
|
|
100
|
+
id: z.string(),
|
|
101
|
+
description: z.string().default(""),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const NotifyTargetSchema = z.object({
|
|
105
|
+
channel: z.string(),
|
|
106
|
+
to: z.string(),
|
|
107
|
+
accountId: z.string().optional(),
|
|
108
|
+
threadId: z.union([z.string(), z.number()]).optional(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const PeerApprovalConfigSchema = z.object({
|
|
112
|
+
enabled: z.boolean().default(false),
|
|
113
|
+
/** "notify" = auto-approve + send notification; "required" = block until approved. */
|
|
114
|
+
mode: z.enum(["notify", "required"]).default("notify"),
|
|
115
|
+
/** Approval timeout in ms (required mode only). Default: 20 minutes. */
|
|
116
|
+
timeout: z.number().default(1_200_000),
|
|
117
|
+
/** Pre-approved nodeIds (always allowed without approval/notification). */
|
|
118
|
+
allowList: z.array(z.string()).default([]),
|
|
119
|
+
/** Path to persist approved peers (relative to workspace .clawmatrix/). */
|
|
120
|
+
persistPath: z.string().default("approved-peers.json"),
|
|
121
|
+
/** Notification targets for approval events (e.g. telegram, feishu). */
|
|
122
|
+
notifyTargets: z.array(NotifyTargetSchema).default([]),
|
|
123
|
+
}).optional();
|
|
124
|
+
|
|
125
|
+
const TerminalConfigSchema = z.object({
|
|
126
|
+
enabled: z.boolean().default(true),
|
|
127
|
+
/** Default shell (auto-detected from $SHELL if not set). */
|
|
128
|
+
shell: z.string().optional(),
|
|
129
|
+
/** Idle session timeout in ms (default: 1800000 = 30 min). */
|
|
130
|
+
sessionTTL: z.number().default(1_800_000),
|
|
131
|
+
/** Max concurrent terminal sessions (default: 3). */
|
|
132
|
+
maxSessions: z.number().default(3),
|
|
133
|
+
/** Allowed nodeIds that can open terminals (empty = all authenticated peers). */
|
|
134
|
+
allowFrom: z.array(z.string()).default([]),
|
|
135
|
+
}).optional();
|
|
136
|
+
|
|
137
|
+
const AcpConfigSchema = z.object({
|
|
138
|
+
enabled: z.boolean().default(false),
|
|
139
|
+
/** ACP agents available on this node. Advertised to peers via capabilities. */
|
|
140
|
+
agents: z.array(AcpAgentInfoSchema).default([]),
|
|
141
|
+
/** Override spawn commands per agent name. Default: agent name is used as the binary. */
|
|
142
|
+
commands: z.record(z.string(), z.array(z.string())).default({}),
|
|
143
|
+
/** Per-turn timeout in ms (default: 600000 = 10 min). */
|
|
144
|
+
timeout: z.number().default(600_000),
|
|
145
|
+
/** Idle persistent session TTL in ms (default: 1800000 = 30 min). */
|
|
146
|
+
sessionTTL: z.number().default(1_800_000),
|
|
147
|
+
/** Max concurrent ACP sessions (default: 5). 0 = unlimited. */
|
|
148
|
+
maxSessions: z.number().default(5),
|
|
149
|
+
}).optional();
|
|
150
|
+
|
|
91
151
|
const RawClawMatrixConfigSchema = z.object({
|
|
92
152
|
nodeId: z.string(),
|
|
93
153
|
secret: z.string().min(16, "secret must be at least 16 characters"),
|
|
94
154
|
listen: z.boolean().default(false),
|
|
95
155
|
listenHost: z.string().default("0.0.0.0"),
|
|
96
|
-
listenPort: z.number().default(
|
|
156
|
+
listenPort: z.number().default(0),
|
|
97
157
|
peers: z.array(PeerConfigSchema).default([]),
|
|
98
158
|
agents: z.array(AgentInfoSchema).default([]),
|
|
99
159
|
models: z.array(ModelInfoSchema).default([]),
|
|
100
160
|
proxyModels: z.array(ProxyModelGroupSchema).default([]),
|
|
101
161
|
tags: z.array(z.string()).default([]),
|
|
102
|
-
proxyPort: z.number().default(
|
|
162
|
+
proxyPort: z.number().default(0),
|
|
103
163
|
toolProxy: ToolProxyConfigSchema.optional(),
|
|
104
164
|
handoffTimeout: z.number().default(600_000),
|
|
165
|
+
modelTimeout: z.number().default(120_000),
|
|
166
|
+
toolTimeout: z.number().default(30_000),
|
|
167
|
+
sentinel: SentinelConfigSchema,
|
|
105
168
|
web: WebConfigSchema,
|
|
106
169
|
knowledge: KnowledgeConfigSchema,
|
|
170
|
+
terminal: TerminalConfigSchema,
|
|
171
|
+
acp: AcpConfigSchema,
|
|
172
|
+
peerApproval: z.union([
|
|
173
|
+
z.boolean(), // true = required mode, false = disabled
|
|
174
|
+
PeerApprovalConfigSchema,
|
|
175
|
+
]).default(true),
|
|
176
|
+
e2ee: z.boolean().default(true),
|
|
177
|
+
compression: z.boolean().default(false),
|
|
107
178
|
});
|
|
108
179
|
|
|
109
180
|
/** Flat proxy model after group expansion (used internally). */
|
|
@@ -121,8 +192,18 @@ export interface ProxyModel {
|
|
|
121
192
|
compat?: z.infer<typeof ModelCompatSchema>;
|
|
122
193
|
}
|
|
123
194
|
|
|
124
|
-
export
|
|
195
|
+
export interface PeerApprovalConfig {
|
|
196
|
+
enabled: boolean;
|
|
197
|
+
mode: "notify" | "required";
|
|
198
|
+
timeout: number;
|
|
199
|
+
allowList: string[];
|
|
200
|
+
persistPath: string;
|
|
201
|
+
notifyTargets: Array<{ channel: string; to: string; accountId?: string; threadId?: string | number }>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels" | "peerApproval"> & {
|
|
125
205
|
proxyModels: ProxyModel[];
|
|
206
|
+
peerApproval: PeerApprovalConfig;
|
|
126
207
|
};
|
|
127
208
|
|
|
128
209
|
export { RawClawMatrixConfigSchema as ClawMatrixConfigSchema };
|
|
@@ -159,5 +240,37 @@ export function parseConfig(raw: unknown): ClawMatrixConfig {
|
|
|
159
240
|
}
|
|
160
241
|
}
|
|
161
242
|
|
|
162
|
-
|
|
243
|
+
// Normalize peerApproval: boolean → object
|
|
244
|
+
let peerApproval: PeerApprovalConfig;
|
|
245
|
+
const rawApproval = parsed.peerApproval;
|
|
246
|
+
if (typeof rawApproval === "boolean") {
|
|
247
|
+
peerApproval = {
|
|
248
|
+
enabled: rawApproval,
|
|
249
|
+
mode: "required",
|
|
250
|
+
timeout: 1_200_000,
|
|
251
|
+
allowList: [],
|
|
252
|
+
persistPath: "approved-peers.json",
|
|
253
|
+
notifyTargets: [],
|
|
254
|
+
};
|
|
255
|
+
} else if (rawApproval) {
|
|
256
|
+
peerApproval = {
|
|
257
|
+
enabled: rawApproval.enabled,
|
|
258
|
+
mode: rawApproval.mode,
|
|
259
|
+
timeout: rawApproval.timeout,
|
|
260
|
+
allowList: rawApproval.allowList,
|
|
261
|
+
persistPath: rawApproval.persistPath,
|
|
262
|
+
notifyTargets: rawApproval.notifyTargets,
|
|
263
|
+
};
|
|
264
|
+
} else {
|
|
265
|
+
peerApproval = {
|
|
266
|
+
enabled: false,
|
|
267
|
+
mode: "notify",
|
|
268
|
+
timeout: 1_200_000,
|
|
269
|
+
allowList: [],
|
|
270
|
+
persistPath: "approved-peers.json",
|
|
271
|
+
notifyTargets: [],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { ...parsed, proxyModels, peerApproval };
|
|
163
276
|
}
|
package/src/connection.ts
CHANGED
|
@@ -12,6 +12,18 @@ import type {
|
|
|
12
12
|
ToolProxyInfo,
|
|
13
13
|
} from "./types.ts";
|
|
14
14
|
import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
|
|
15
|
+
import {
|
|
16
|
+
generateX25519KeyPair,
|
|
17
|
+
deriveSessionKey,
|
|
18
|
+
derivePerPeerSecret,
|
|
19
|
+
encryptBinary,
|
|
20
|
+
decryptBinary,
|
|
21
|
+
isBinaryEncrypted,
|
|
22
|
+
publicKeyToBase64,
|
|
23
|
+
base64ToPublicKey,
|
|
24
|
+
type KeyPair,
|
|
25
|
+
} from "./crypto.ts";
|
|
26
|
+
import { debug } from "./debug.ts";
|
|
15
27
|
|
|
16
28
|
const HEARTBEAT_BASE = 12_000;
|
|
17
29
|
const HEARTBEAT_JITTER = 6_000;
|
|
@@ -35,6 +47,15 @@ export interface ConnectionEvents {
|
|
|
35
47
|
error: [error: Error];
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
export interface ConnectionE2eeOptions {
|
|
51
|
+
e2ee?: boolean;
|
|
52
|
+
compression?: boolean;
|
|
53
|
+
/** Defer sending auth_ok for inbound connections until completeAuth() is called. */
|
|
54
|
+
deferAuthOk?: boolean;
|
|
55
|
+
/** Persistent identity key pair (TOFU). If provided, used instead of ephemeral keys. */
|
|
56
|
+
identityKeyPair?: import("./crypto.ts").KeyPair;
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
export class Connection extends EventEmitter<ConnectionEvents> {
|
|
39
60
|
readonly role: ConnectionRole;
|
|
40
61
|
private transport: WsTransport;
|
|
@@ -45,8 +66,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
45
66
|
remoteNodeId: string | null = null;
|
|
46
67
|
remoteCapabilities: NodeCapabilities | null = null;
|
|
47
68
|
authenticated = false;
|
|
69
|
+
/** Whether this connection has an active E2EE session. */
|
|
70
|
+
get encrypted(): boolean { return this.sessionKey !== null; }
|
|
71
|
+
/** Remote peer's persistent public key (available after E2EE handshake). */
|
|
72
|
+
get remoteIdentityKey(): string | null {
|
|
73
|
+
return this._remotePublicKey ? publicKeyToBase64(this._remotePublicKey) : null;
|
|
74
|
+
}
|
|
48
75
|
|
|
49
76
|
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
77
|
+
private dummyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
50
78
|
private authTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
79
|
private missedPongs = 0;
|
|
52
80
|
private pendingNonce: string | null = null;
|
|
@@ -55,12 +83,25 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
55
83
|
/** Exponential moving average of heartbeat RTT in milliseconds. */
|
|
56
84
|
latencyMs = 0;
|
|
57
85
|
|
|
86
|
+
// ── Deferred auth ──────────────────────────────────────────────
|
|
87
|
+
private deferAuthOk: boolean;
|
|
88
|
+
/** Whether auth_ok has been sent (for deferred mode). */
|
|
89
|
+
private authOkSent = false;
|
|
90
|
+
|
|
91
|
+
// ── E2EE state ──────────────────────────────────────────────────
|
|
92
|
+
private e2eeEnabled: boolean;
|
|
93
|
+
private compressionEnabled: boolean;
|
|
94
|
+
private localKeyPair: KeyPair | null = null;
|
|
95
|
+
private _remotePublicKey: Buffer | null = null;
|
|
96
|
+
private sessionKey: Buffer | null = null;
|
|
97
|
+
|
|
58
98
|
constructor(
|
|
59
99
|
transport: WsTransport,
|
|
60
100
|
role: ConnectionRole,
|
|
61
101
|
nodeId: string,
|
|
62
102
|
secret: string,
|
|
63
103
|
localCapabilities: NodeCapabilities,
|
|
104
|
+
e2eeOptions?: ConnectionE2eeOptions,
|
|
64
105
|
) {
|
|
65
106
|
super();
|
|
66
107
|
this.transport = transport;
|
|
@@ -68,6 +109,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
68
109
|
this.nodeId = nodeId;
|
|
69
110
|
this.secret = secret;
|
|
70
111
|
this.localCapabilities = localCapabilities;
|
|
112
|
+
this.deferAuthOk = e2eeOptions?.deferAuthOk ?? false;
|
|
113
|
+
this.e2eeEnabled = e2eeOptions?.e2ee ?? true;
|
|
114
|
+
this.compressionEnabled = e2eeOptions?.compression ?? false;
|
|
115
|
+
|
|
116
|
+
if (this.e2eeEnabled) {
|
|
117
|
+
// Use persistent identity key pair if provided (TOFU model),
|
|
118
|
+
// otherwise generate an ephemeral key pair per connection.
|
|
119
|
+
this.localKeyPair = e2eeOptions?.identityKeyPair ?? generateX25519KeyPair();
|
|
120
|
+
}
|
|
71
121
|
|
|
72
122
|
// Both inbound and outbound get an auth timeout to prevent hanging connections
|
|
73
123
|
this.authTimer = setTimeout(() => {
|
|
@@ -106,165 +156,295 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
106
156
|
private async sendAuthChallenge() {
|
|
107
157
|
const nonce = generateNonce();
|
|
108
158
|
this.pendingNonce = nonce;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
159
|
+
// Minimal obfuscated format: no type/from fields, just short keys
|
|
160
|
+
// n=nonce, k=publicKey (if E2EE), i=nodeId (for per-peer secret derivation)
|
|
161
|
+
const msg: Record<string, string> = { n: nonce, i: this.nodeId };
|
|
162
|
+
if (this.localKeyPair) {
|
|
163
|
+
msg.k = publicKeyToBase64(this.localKeyPair.publicKey);
|
|
164
|
+
}
|
|
165
|
+
this.sendRaw(msg);
|
|
115
166
|
}
|
|
116
167
|
|
|
117
168
|
// ── Send helpers ───────────────────────────────────────────────
|
|
169
|
+
/** Send a frame, encrypting the entire frame if a session key is established. */
|
|
118
170
|
send(frame: ClusterFrame | AnyClusterFrame) {
|
|
119
171
|
if (this.closed) return;
|
|
120
|
-
this.
|
|
172
|
+
if (this.sessionKey) {
|
|
173
|
+
// Full frame encryption → binary base64 envelope (no JSON structure on wire)
|
|
174
|
+
this.sendRaw(encryptBinary(this.sessionKey, JSON.stringify(frame), this.compressionEnabled));
|
|
175
|
+
} else {
|
|
176
|
+
this.sendRaw(frame);
|
|
177
|
+
}
|
|
121
178
|
}
|
|
122
179
|
|
|
180
|
+
/** Send raw data. Strings sent as-is (for binary envelopes); objects JSON-encoded. */
|
|
123
181
|
private sendRaw(data: unknown) {
|
|
124
182
|
if (this.transport.readyState === WebSocket.OPEN) {
|
|
125
|
-
this.transport.send(JSON.stringify(data));
|
|
183
|
+
this.transport.send(typeof data === "string" ? data : JSON.stringify(data));
|
|
126
184
|
}
|
|
127
185
|
}
|
|
128
186
|
|
|
129
187
|
// ── Message dispatch ───────────────────────────────────────────
|
|
130
188
|
private async onRawMessage(data: unknown) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
189
|
+
const str = typeof data === "string" ? data : String(data);
|
|
190
|
+
if (!str.length) return;
|
|
191
|
+
|
|
192
|
+
let frame: AnyClusterFrame | undefined;
|
|
193
|
+
|
|
194
|
+
// Binary encrypted envelope (base64, not JSON)
|
|
195
|
+
if (this.sessionKey && isBinaryEncrypted(str)) {
|
|
196
|
+
try {
|
|
197
|
+
frame = JSON.parse(decryptBinary(this.sessionKey, str));
|
|
198
|
+
} catch (err) {
|
|
199
|
+
debug("e2ee", `Frame decryption failed: ${err}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Discard dummy traffic
|
|
203
|
+
if ((frame as any).type === "_d") return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// JSON: handshake messages or plaintext frames
|
|
207
|
+
if (!frame) {
|
|
208
|
+
try {
|
|
209
|
+
frame = JSON.parse(str);
|
|
210
|
+
} catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
136
213
|
}
|
|
137
214
|
|
|
138
215
|
if (!this.authenticated) {
|
|
139
|
-
await this.handleAuthMessage(frame);
|
|
216
|
+
await this.handleAuthMessage(frame!);
|
|
140
217
|
return;
|
|
141
218
|
}
|
|
142
219
|
|
|
143
|
-
|
|
144
|
-
|
|
220
|
+
// Approval pending: HMAC passed but auth_ok not yet sent.
|
|
221
|
+
// Only allow ping/pong — drop all business frames.
|
|
222
|
+
if (!this.authOkSent) {
|
|
223
|
+
if (frame!.type !== "ping" && frame!.type !== "pong") return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (frame!.type === "ping") {
|
|
227
|
+
this.send({
|
|
145
228
|
type: "pong",
|
|
146
229
|
from: this.nodeId,
|
|
147
230
|
timestamp: Date.now(),
|
|
148
|
-
});
|
|
231
|
+
} as AnyClusterFrame);
|
|
149
232
|
return;
|
|
150
233
|
}
|
|
151
|
-
if (frame
|
|
234
|
+
if (frame!.type === "pong") {
|
|
152
235
|
this.missedPongs = 0;
|
|
153
236
|
if (this.lastPingSentAt > 0) {
|
|
154
237
|
const rtt = Date.now() - this.lastPingSentAt;
|
|
155
|
-
// Exponential moving average (α = 0.3)
|
|
156
238
|
this.latencyMs = this.latencyMs === 0 ? rtt : Math.round(this.latencyMs * 0.7 + rtt * 0.3);
|
|
157
239
|
this.emit("latency", this.latencyMs);
|
|
158
240
|
}
|
|
159
241
|
return;
|
|
160
242
|
}
|
|
161
243
|
|
|
162
|
-
this.emit("message", frame);
|
|
244
|
+
this.emit("message", frame!);
|
|
163
245
|
}
|
|
164
246
|
|
|
165
|
-
private async handleAuthMessage(frame:
|
|
247
|
+
private async handleAuthMessage(frame: any) {
|
|
166
248
|
if (this.role === "inbound") {
|
|
249
|
+
// ── Obfuscated E2EE key exchange: {"k":"<pubkey>"} ──
|
|
250
|
+
if (frame.k && !frame.n && !frame.type) {
|
|
251
|
+
if (this.localKeyPair) {
|
|
252
|
+
this._remotePublicKey = base64ToPublicKey(frame.k);
|
|
253
|
+
const nonceSalt = this.pendingNonce ? Buffer.from(this.pendingNonce) : undefined;
|
|
254
|
+
this.sessionKey = deriveSessionKey(this.localKeyPair, this._remotePublicKey, nonceSalt);
|
|
255
|
+
debug("e2ee", "Session key derived from key exchange");
|
|
256
|
+
}
|
|
257
|
+
return; // Wait for encrypted auth_verify
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Encrypted auth_verify (decrypted from binary envelope in onRawMessage) ──
|
|
261
|
+
if (frame.type === "auth_verify") {
|
|
262
|
+
const { nodeId, sig, agents, models, tags, deviceInfo, toolProxy, acpAgents } = frame.payload as {
|
|
263
|
+
nodeId: string; sig: string;
|
|
264
|
+
agents?: AgentInfo[]; models?: ModelInfo[]; tags?: string[];
|
|
265
|
+
deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo;
|
|
266
|
+
acpAgents?: import("./types.ts").AcpAgentInfo[];
|
|
267
|
+
};
|
|
268
|
+
if (!this.pendingNonce) return;
|
|
269
|
+
|
|
270
|
+
const peerSecret = derivePerPeerSecret(this.secret, this.nodeId, nodeId);
|
|
271
|
+
const valid = await verifyHmac(this.pendingNonce, peerSecret, sig);
|
|
272
|
+
this.pendingNonce = null;
|
|
273
|
+
|
|
274
|
+
if (!valid) {
|
|
275
|
+
// Minimal error response — no identifiable type strings
|
|
276
|
+
this.sendRaw({ e: "sig" });
|
|
277
|
+
this.close(4001, "auth failed");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.remoteNodeId = nodeId;
|
|
282
|
+
this.remoteCapabilities = {
|
|
283
|
+
nodeId, agents: agents ?? [], models: models ?? [],
|
|
284
|
+
tags: tags ?? [], deviceInfo, toolProxy, acpAgents,
|
|
285
|
+
};
|
|
286
|
+
this.authenticated = true;
|
|
287
|
+
this.clearAuthTimer();
|
|
288
|
+
|
|
289
|
+
if (this.deferAuthOk) {
|
|
290
|
+
// Don't send auth_ok yet — wait for completeAuth() after approval
|
|
291
|
+
this.emit("authenticated", this.remoteCapabilities);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.sendAuthOkAndStart();
|
|
296
|
+
this.emit("authenticated", this.remoteCapabilities);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Legacy plaintext auth (E2EE disabled) ──
|
|
167
301
|
if (frame.type !== "auth") return;
|
|
168
302
|
const { nodeId, sig, agents, models, tags, deviceInfo, toolProxy } = frame.payload as {
|
|
169
|
-
nodeId: string;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
models?: ModelInfo[];
|
|
173
|
-
tags?: string[];
|
|
174
|
-
deviceInfo?: DeviceInfo;
|
|
175
|
-
toolProxy?: ToolProxyInfo;
|
|
303
|
+
nodeId: string; sig: string;
|
|
304
|
+
agents?: AgentInfo[]; models?: ModelInfo[]; tags?: string[];
|
|
305
|
+
deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo;
|
|
176
306
|
};
|
|
177
307
|
if (!this.pendingNonce) return;
|
|
178
308
|
|
|
179
|
-
const
|
|
309
|
+
const peerSecret = derivePerPeerSecret(this.secret, this.nodeId, nodeId);
|
|
310
|
+
const valid = await verifyHmac(this.pendingNonce, peerSecret, sig);
|
|
180
311
|
this.pendingNonce = null;
|
|
181
312
|
|
|
182
313
|
if (!valid) {
|
|
183
|
-
this.sendRaw({
|
|
184
|
-
type: "auth_fail",
|
|
185
|
-
from: this.nodeId,
|
|
186
|
-
timestamp: Date.now(),
|
|
187
|
-
payload: { reason: "Invalid signature" },
|
|
188
|
-
} satisfies AuthFail);
|
|
314
|
+
this.sendRaw({ e: "sig" });
|
|
189
315
|
this.close(4001, "auth failed");
|
|
190
316
|
return;
|
|
191
317
|
}
|
|
192
318
|
|
|
193
319
|
this.remoteNodeId = nodeId;
|
|
194
320
|
this.remoteCapabilities = {
|
|
195
|
-
nodeId,
|
|
196
|
-
|
|
197
|
-
models: models ?? [],
|
|
198
|
-
tags: tags ?? [],
|
|
199
|
-
deviceInfo,
|
|
200
|
-
toolProxy,
|
|
321
|
+
nodeId, agents: agents ?? [], models: models ?? [],
|
|
322
|
+
tags: tags ?? [], deviceInfo, toolProxy,
|
|
201
323
|
};
|
|
202
324
|
this.authenticated = true;
|
|
203
325
|
this.clearAuthTimer();
|
|
204
326
|
|
|
205
|
-
this.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
agents: this.localCapabilities.agents,
|
|
212
|
-
models: this.localCapabilities.models,
|
|
213
|
-
tags: this.localCapabilities.tags,
|
|
214
|
-
deviceInfo: this.localCapabilities.deviceInfo,
|
|
215
|
-
toolProxy: this.localCapabilities.toolProxy,
|
|
216
|
-
},
|
|
217
|
-
} as AuthOk);
|
|
218
|
-
|
|
219
|
-
this.startHeartbeat();
|
|
220
|
-
this.emit("authenticated", this.remoteCapabilities);
|
|
327
|
+
if (this.deferAuthOk) {
|
|
328
|
+
this.emit("authenticated", this.remoteCapabilities);
|
|
329
|
+
} else {
|
|
330
|
+
this.sendAuthOkAndStart();
|
|
331
|
+
this.emit("authenticated", this.remoteCapabilities);
|
|
332
|
+
}
|
|
221
333
|
} else {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
334
|
+
// ── Outbound (client) ──
|
|
335
|
+
|
|
336
|
+
// Obfuscated challenge: {"n":"<nonce>","k":"<pubkey>","i":"<nodeId>"}
|
|
337
|
+
if (frame.n && !frame.type) {
|
|
338
|
+
const nonce: string = frame.n;
|
|
339
|
+
const publicKey: string | undefined = frame.k;
|
|
340
|
+
const serverNodeId: string = frame.i;
|
|
341
|
+
|
|
342
|
+
if (publicKey && this.localKeyPair) {
|
|
343
|
+
// E2EE: derive session key, send key exchange + encrypted auth_verify
|
|
344
|
+
this._remotePublicKey = base64ToPublicKey(publicKey);
|
|
345
|
+
this.sessionKey = deriveSessionKey(this.localKeyPair, this._remotePublicKey, Buffer.from(nonce));
|
|
346
|
+
debug("e2ee", "Session key derived from challenge");
|
|
347
|
+
|
|
348
|
+
// Key exchange: minimal {"k":"<pubkey>"} (plaintext, server needs it)
|
|
349
|
+
this.sendRaw({ k: publicKeyToBase64(this.localKeyPair.publicKey) });
|
|
350
|
+
|
|
351
|
+
// auth_verify: binary encrypted via send()
|
|
352
|
+
const sig = await computeHmac(nonce, derivePerPeerSecret(this.secret, this.nodeId, serverNodeId));
|
|
353
|
+
this.send({
|
|
354
|
+
type: "auth_verify", from: this.nodeId, timestamp: Date.now(),
|
|
355
|
+
payload: {
|
|
356
|
+
nodeId: this.nodeId, sig,
|
|
357
|
+
agents: this.localCapabilities.agents,
|
|
358
|
+
models: this.localCapabilities.models,
|
|
359
|
+
tags: this.localCapabilities.tags,
|
|
360
|
+
deviceInfo: this.localCapabilities.deviceInfo,
|
|
361
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
362
|
+
},
|
|
363
|
+
} as AnyClusterFrame);
|
|
364
|
+
} else {
|
|
365
|
+
// Legacy plaintext (no E2EE)
|
|
366
|
+
const sig = await computeHmac(nonce, derivePerPeerSecret(this.secret, this.nodeId, serverNodeId));
|
|
367
|
+
this.sendRaw({
|
|
368
|
+
type: "auth", from: this.nodeId, timestamp: Date.now(),
|
|
369
|
+
payload: {
|
|
370
|
+
nodeId: this.nodeId, sig,
|
|
371
|
+
agents: this.localCapabilities.agents,
|
|
372
|
+
models: this.localCapabilities.models,
|
|
373
|
+
tags: this.localCapabilities.tags,
|
|
374
|
+
deviceInfo: this.localCapabilities.deviceInfo,
|
|
375
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
239
379
|
return;
|
|
240
380
|
}
|
|
241
381
|
|
|
382
|
+
// auth_ok (decrypted from binary envelope, or plaintext legacy)
|
|
242
383
|
if (frame.type === "auth_ok") {
|
|
243
384
|
const ok = frame as AuthOk;
|
|
244
385
|
this.remoteNodeId = ok.payload.nodeId;
|
|
245
386
|
this.remoteCapabilities = {
|
|
246
|
-
nodeId: ok.payload.nodeId,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
deviceInfo: ok.payload.deviceInfo,
|
|
251
|
-
toolProxy: ok.payload.toolProxy,
|
|
387
|
+
nodeId: ok.payload.nodeId, agents: ok.payload.agents,
|
|
388
|
+
models: ok.payload.models, tags: ok.payload.tags,
|
|
389
|
+
deviceInfo: ok.payload.deviceInfo, toolProxy: ok.payload.toolProxy,
|
|
390
|
+
acpAgents: ok.payload.acpAgents,
|
|
252
391
|
};
|
|
253
392
|
this.authenticated = true;
|
|
393
|
+
this.authOkSent = true; // outbound: auth complete, allow business frames
|
|
254
394
|
this.clearAuthTimer();
|
|
395
|
+
|
|
255
396
|
this.startHeartbeat();
|
|
397
|
+
this.startDummyTraffic();
|
|
256
398
|
this.emit("authenticated", this.remoteCapabilities);
|
|
257
399
|
return;
|
|
258
400
|
}
|
|
259
401
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
402
|
+
// Auth failure: {"e":"sig"} or legacy {"type":"auth_fail",...}
|
|
403
|
+
if (frame.e || frame.type === "auth_fail") {
|
|
404
|
+
const reason = frame.e || (frame.payload?.reason ?? "unknown");
|
|
405
|
+
this.emit("error", new Error(`Auth failed: ${reason}`));
|
|
263
406
|
this.close(4001, "auth failed");
|
|
264
407
|
}
|
|
265
408
|
}
|
|
266
409
|
}
|
|
267
410
|
|
|
411
|
+
/** Send auth_ok with capabilities and start heartbeat/dummy traffic. */
|
|
412
|
+
private sendAuthOkAndStart() {
|
|
413
|
+
if (this.authOkSent) return;
|
|
414
|
+
this.authOkSent = true;
|
|
415
|
+
const authOk = {
|
|
416
|
+
type: "auth_ok", from: this.nodeId, timestamp: Date.now(),
|
|
417
|
+
payload: {
|
|
418
|
+
nodeId: this.nodeId,
|
|
419
|
+
agents: this.localCapabilities.agents,
|
|
420
|
+
models: this.localCapabilities.models,
|
|
421
|
+
tags: this.localCapabilities.tags,
|
|
422
|
+
deviceInfo: this.localCapabilities.deviceInfo,
|
|
423
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
424
|
+
acpAgents: this.localCapabilities.acpAgents,
|
|
425
|
+
},
|
|
426
|
+
} as AuthOk;
|
|
427
|
+
|
|
428
|
+
if (this.sessionKey) {
|
|
429
|
+
this.send(authOk);
|
|
430
|
+
} else {
|
|
431
|
+
this.sendRaw(authOk);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.startHeartbeat();
|
|
435
|
+
this.startDummyTraffic();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Complete deferred auth — send auth_ok and start heartbeat.
|
|
440
|
+
* Call this after peer approval succeeds. Only applies to inbound connections
|
|
441
|
+
* with deferAuthOk enabled. No-op otherwise.
|
|
442
|
+
*/
|
|
443
|
+
completeAuth() {
|
|
444
|
+
if (!this.authenticated || this.closed || this.role !== "inbound") return;
|
|
445
|
+
this.sendAuthOkAndStart();
|
|
446
|
+
}
|
|
447
|
+
|
|
268
448
|
private clearAuthTimer() {
|
|
269
449
|
if (this.authTimer) {
|
|
270
450
|
clearTimeout(this.authTimer);
|
|
@@ -286,11 +466,26 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
286
466
|
return;
|
|
287
467
|
}
|
|
288
468
|
this.lastPingSentAt = Date.now();
|
|
289
|
-
this.
|
|
469
|
+
this.send({
|
|
290
470
|
type: "ping",
|
|
291
471
|
from: this.nodeId,
|
|
292
472
|
timestamp: this.lastPingSentAt,
|
|
293
|
-
});
|
|
473
|
+
} as AnyClusterFrame);
|
|
474
|
+
scheduleNext();
|
|
475
|
+
}, interval);
|
|
476
|
+
};
|
|
477
|
+
scheduleNext();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Dummy traffic (breaks heartbeat timing pattern) ────────────
|
|
481
|
+
private startDummyTraffic() {
|
|
482
|
+
if (!this.sessionKey) return;
|
|
483
|
+
const scheduleNext = () => {
|
|
484
|
+
// Random interval 2-8 seconds — interleaves with heartbeat to obscure pattern
|
|
485
|
+
const interval = 2_000 + Math.random() * 6_000;
|
|
486
|
+
this.dummyTimer = setTimeout(() => {
|
|
487
|
+
if (this.closed || !this.sessionKey) return;
|
|
488
|
+
this.send({ type: "_d", from: "", timestamp: 0 } as unknown as AnyClusterFrame);
|
|
294
489
|
scheduleNext();
|
|
295
490
|
}, interval);
|
|
296
491
|
};
|
|
@@ -306,6 +501,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
306
501
|
clearTimeout(this.heartbeatTimer);
|
|
307
502
|
this.heartbeatTimer = null;
|
|
308
503
|
}
|
|
504
|
+
if (this.dummyTimer) {
|
|
505
|
+
clearTimeout(this.dummyTimer);
|
|
506
|
+
this.dummyTimer = null;
|
|
507
|
+
}
|
|
508
|
+
// Clear session key material (don't null localKeyPair if it's a
|
|
509
|
+
// persistent identity owned by the caller)
|
|
510
|
+
this.sessionKey = null;
|
|
511
|
+
this._remotePublicKey = null;
|
|
309
512
|
try {
|
|
310
513
|
this.transport.close(code, reason);
|
|
311
514
|
} catch {
|