@soyeht/soyeht 0.1.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 +73 -0
- package/docs/PROTOCOL.md +388 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +49 -0
- package/src/channel.ts +157 -0
- package/src/config.ts +203 -0
- package/src/crypto.ts +227 -0
- package/src/envelope-v2.ts +201 -0
- package/src/http.ts +175 -0
- package/src/identity.ts +157 -0
- package/src/index.ts +120 -0
- package/src/media.ts +100 -0
- package/src/openclaw-plugin-sdk.d.ts +209 -0
- package/src/outbound.ts +198 -0
- package/src/pairing.ts +324 -0
- package/src/ratchet.ts +262 -0
- package/src/rpc.ts +503 -0
- package/src/security.ts +158 -0
- package/src/service.ts +177 -0
- package/src/types.ts +213 -0
- package/src/version.ts +1 -0
- package/src/x3dh.ts +105 -0
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GatewayRequestHandler,
|
|
3
|
+
OpenClawPluginApi,
|
|
4
|
+
} from "openclaw/plugin-sdk";
|
|
5
|
+
import { SOYEHT_CAPABILITIES } from "./types.js";
|
|
6
|
+
import {
|
|
7
|
+
resolveSoyehtAccount,
|
|
8
|
+
listSoyehtAccountIds,
|
|
9
|
+
normalizeAccountId,
|
|
10
|
+
} from "./config.js";
|
|
11
|
+
import {
|
|
12
|
+
deliverTextMessage,
|
|
13
|
+
postToBackend,
|
|
14
|
+
buildOutboundEnvelope,
|
|
15
|
+
type PostToBackendOptions,
|
|
16
|
+
} from "./outbound.js";
|
|
17
|
+
import type { TextMessagePayload } from "./types.js";
|
|
18
|
+
import {
|
|
19
|
+
base64UrlDecode,
|
|
20
|
+
generateX25519KeyPair,
|
|
21
|
+
importEd25519PublicKey,
|
|
22
|
+
importX25519PublicKey,
|
|
23
|
+
isTimestampValid,
|
|
24
|
+
} from "./crypto.js";
|
|
25
|
+
import {
|
|
26
|
+
buildHandshakeTranscript,
|
|
27
|
+
performX3DH,
|
|
28
|
+
signHandshakeTranscript,
|
|
29
|
+
verifyHandshakeTranscript,
|
|
30
|
+
} from "./x3dh.js";
|
|
31
|
+
import { saveSession } from "./identity.js";
|
|
32
|
+
import { zeroBuffer, type RatchetState } from "./ratchet.js";
|
|
33
|
+
import type { SecurityV2Deps } from "./service.js";
|
|
34
|
+
import { PLUGIN_VERSION } from "./version.js";
|
|
35
|
+
|
|
36
|
+
const MIN_HANDSHAKE_TOLERANCE_MS = 30_000;
|
|
37
|
+
const MAX_HANDSHAKE_TOLERANCE_MS = 120_000;
|
|
38
|
+
|
|
39
|
+
function buildPendingHandshakeKey(accountId: string, nonce: string): string {
|
|
40
|
+
return `${accountId}:${nonce}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isWellFormedNonce(nonce: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
const decoded = base64UrlDecode(nonce);
|
|
46
|
+
return decoded.length >= 16 && decoded.length <= 64;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clearExistingSession(v2deps: SecurityV2Deps, accountId: string): void {
|
|
53
|
+
const existing = v2deps.sessions.get(accountId);
|
|
54
|
+
if (!existing) return;
|
|
55
|
+
zeroBuffer(existing.rootKey);
|
|
56
|
+
zeroBuffer(existing.sending.chainKey);
|
|
57
|
+
zeroBuffer(existing.receiving.chainKey);
|
|
58
|
+
v2deps.sessions.delete(accountId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// soyeht.status
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export function handleStatus(api: OpenClawPluginApi): GatewayRequestHandler {
|
|
66
|
+
return async ({ respond }) => {
|
|
67
|
+
const cfg = await api.runtime.config.loadConfig();
|
|
68
|
+
const accountIds = listSoyehtAccountIds(cfg);
|
|
69
|
+
const accounts = accountIds.map((id) => {
|
|
70
|
+
const account = resolveSoyehtAccount(cfg, id);
|
|
71
|
+
return {
|
|
72
|
+
accountId: id,
|
|
73
|
+
enabled: account.enabled,
|
|
74
|
+
configured: Boolean(account.backendBaseUrl && account.pluginAuthToken),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
respond(true, {
|
|
79
|
+
plugin: "soyeht",
|
|
80
|
+
version: PLUGIN_VERSION,
|
|
81
|
+
accounts,
|
|
82
|
+
capabilities: SOYEHT_CAPABILITIES,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// soyeht.capabilities
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export function handleCapabilities(_api: OpenClawPluginApi): GatewayRequestHandler {
|
|
92
|
+
return async ({ respond }) => {
|
|
93
|
+
respond(true, SOYEHT_CAPABILITIES);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// soyeht.notify — proactive outbound text
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export function handleNotify(
|
|
102
|
+
api: OpenClawPluginApi,
|
|
103
|
+
v2deps?: SecurityV2Deps,
|
|
104
|
+
): GatewayRequestHandler {
|
|
105
|
+
return async ({ params, respond }) => {
|
|
106
|
+
const cfg = await api.runtime.config.loadConfig();
|
|
107
|
+
const accountId = (params["accountId"] as string) ?? undefined;
|
|
108
|
+
const account = resolveSoyehtAccount(cfg, accountId);
|
|
109
|
+
|
|
110
|
+
if (!account.allowProactive) {
|
|
111
|
+
respond(false, undefined, {
|
|
112
|
+
code: "PROACTIVE_DISABLED",
|
|
113
|
+
message: "Proactive notifications are disabled for this account",
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!account.backendBaseUrl || !account.pluginAuthToken) {
|
|
119
|
+
respond(false, undefined, {
|
|
120
|
+
code: "NOT_CONFIGURED",
|
|
121
|
+
message: "Account is not fully configured",
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const to = params["to"] as string;
|
|
127
|
+
const text = params["text"] as string;
|
|
128
|
+
if (!to || !text) {
|
|
129
|
+
respond(false, undefined, {
|
|
130
|
+
code: "INVALID_PARAMS",
|
|
131
|
+
message: "Missing required params: to, text",
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (account.security.enabled && v2deps) {
|
|
137
|
+
const ratchetSession = v2deps.sessions.get(account.accountId);
|
|
138
|
+
if (!ratchetSession) {
|
|
139
|
+
respond(false, undefined, {
|
|
140
|
+
code: "SESSION_REQUIRED",
|
|
141
|
+
message: "V2 session required for secure delivery",
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (ratchetSession.expiresAt < Date.now()) {
|
|
147
|
+
respond(false, undefined, {
|
|
148
|
+
code: "SESSION_EXPIRED",
|
|
149
|
+
message: "V2 session has expired, re-handshake required",
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const message: TextMessagePayload = { contentType: "text", text };
|
|
155
|
+
const envelope = buildOutboundEnvelope(account.accountId, to, message);
|
|
156
|
+
const opts: PostToBackendOptions = {
|
|
157
|
+
ratchetSession,
|
|
158
|
+
dhRatchetCfg: {
|
|
159
|
+
intervalMessages: account.security.dhRatchetIntervalMessages,
|
|
160
|
+
intervalMs: account.security.dhRatchetIntervalMs,
|
|
161
|
+
},
|
|
162
|
+
onSessionUpdated: (updated) => v2deps.sessions.set(account.accountId, updated),
|
|
163
|
+
securityEnabled: true,
|
|
164
|
+
};
|
|
165
|
+
const result = await postToBackend(
|
|
166
|
+
account.backendBaseUrl,
|
|
167
|
+
account.pluginAuthToken,
|
|
168
|
+
envelope,
|
|
169
|
+
opts,
|
|
170
|
+
);
|
|
171
|
+
respond(true, { deliveryId: result.messageId, meta: result.meta });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result = await deliverTextMessage({
|
|
176
|
+
backendBaseUrl: account.backendBaseUrl,
|
|
177
|
+
pluginAuthToken: account.pluginAuthToken,
|
|
178
|
+
accountId: account.accountId,
|
|
179
|
+
sessionId: to,
|
|
180
|
+
to,
|
|
181
|
+
text,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
respond(true, { deliveryId: result.messageId, meta: result.meta });
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// soyeht.livekit.prepare — V2 stub
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
export function handleLiveKitPrepare(_api: OpenClawPluginApi): GatewayRequestHandler {
|
|
193
|
+
return async ({ respond }) => {
|
|
194
|
+
respond(true, {
|
|
195
|
+
enabled: false,
|
|
196
|
+
reason: "livekit_not_enabled",
|
|
197
|
+
voiceContractVersion: 1,
|
|
198
|
+
pipeline: "stt->llm->tts",
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// soyeht.security.handshake(.init) — step 1 of a two-step authenticated X3DH
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export function handleSecurityHandshake(
|
|
208
|
+
api: OpenClawPluginApi,
|
|
209
|
+
v2deps: SecurityV2Deps,
|
|
210
|
+
): GatewayRequestHandler {
|
|
211
|
+
return async ({ params, respond }) => {
|
|
212
|
+
if (!v2deps.ready) {
|
|
213
|
+
respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!v2deps.identity) {
|
|
217
|
+
respond(false, undefined, { code: "NO_IDENTITY", message: "Identity not initialized" });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const accountId = normalizeAccountId(params["accountId"] as string | undefined);
|
|
222
|
+
const cfg = await api.runtime.config.loadConfig();
|
|
223
|
+
const account = resolveSoyehtAccount(cfg, accountId);
|
|
224
|
+
const { allowed } = v2deps.rateLimiter.check(`handshake:init:${accountId}`, account.security.rateLimit);
|
|
225
|
+
if (!allowed) {
|
|
226
|
+
respond(false, undefined, { code: "RATE_LIMITED", message: "Too many requests" });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const appEphemeralKey = params["appEphemeralKey"] as string | undefined;
|
|
231
|
+
const nonce = params["nonce"] as string | undefined;
|
|
232
|
+
const timestamp = params["timestamp"] as number | undefined;
|
|
233
|
+
|
|
234
|
+
if (!appEphemeralKey || !nonce || typeof timestamp !== "number") {
|
|
235
|
+
respond(false, undefined, {
|
|
236
|
+
code: "INVALID_PARAMS",
|
|
237
|
+
message: "Missing required params: accountId, appEphemeralKey, nonce, timestamp",
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!isWellFormedNonce(nonce)) {
|
|
243
|
+
respond(false, undefined, { code: "INVALID_NONCE", message: "nonce_must_be_base64url_16_to_64_bytes" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const peer = v2deps.peers.get(accountId);
|
|
248
|
+
if (!peer) {
|
|
249
|
+
respond(false, undefined, {
|
|
250
|
+
code: "PEER_NOT_FOUND",
|
|
251
|
+
message: "Peer not registered. Complete QR pairing first.",
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
importX25519PublicKey(appEphemeralKey);
|
|
258
|
+
} catch {
|
|
259
|
+
respond(false, undefined, { code: "INVALID_KEY", message: "Invalid appEphemeralKey" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const handshakeTolerance = Math.min(account.security.timestampToleranceMs, MAX_HANDSHAKE_TOLERANCE_MS);
|
|
264
|
+
const tolerance = Math.max(handshakeTolerance, MIN_HANDSHAKE_TOLERANCE_MS);
|
|
265
|
+
if (!isTimestampValid(timestamp, tolerance)) {
|
|
266
|
+
respond(false, undefined, { code: "TIMESTAMP_OUT_OF_RANGE", message: "timestamp_out_of_range" });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!v2deps.nonceCache.add(`handshake:${accountId}:${nonce}`)) {
|
|
271
|
+
respond(false, undefined, { code: "NONCE_REUSED", message: "nonce_reused" });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const [key, pending] of v2deps.pendingHandshakes) {
|
|
276
|
+
if (pending.accountId === accountId) {
|
|
277
|
+
v2deps.pendingHandshakes.delete(key);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const challengeExpiresAt = now + tolerance;
|
|
283
|
+
const sessionExpiresAt = now + account.security.sessionMaxAgeMs;
|
|
284
|
+
const pluginEphemeralKey = generateX25519KeyPair();
|
|
285
|
+
const transcript = buildHandshakeTranscript({
|
|
286
|
+
accountId,
|
|
287
|
+
appEphKeyB64: appEphemeralKey,
|
|
288
|
+
pluginEphKeyB64: pluginEphemeralKey.publicKeyB64,
|
|
289
|
+
nonce,
|
|
290
|
+
timestamp,
|
|
291
|
+
expiresAt: sessionExpiresAt,
|
|
292
|
+
});
|
|
293
|
+
const pluginSignature = signHandshakeTranscript(
|
|
294
|
+
v2deps.identity.signKey.privateKey,
|
|
295
|
+
transcript,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const pendingKey = buildPendingHandshakeKey(accountId, nonce);
|
|
299
|
+
v2deps.pendingHandshakes.set(pendingKey, {
|
|
300
|
+
key: pendingKey,
|
|
301
|
+
accountId,
|
|
302
|
+
nonce,
|
|
303
|
+
appEphemeralKey,
|
|
304
|
+
pluginEphemeralKey,
|
|
305
|
+
transcript,
|
|
306
|
+
challengeExpiresAt,
|
|
307
|
+
sessionExpiresAt,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
api.logger.info("[soyeht] V2 handshake init accepted", { accountId });
|
|
311
|
+
|
|
312
|
+
respond(true, {
|
|
313
|
+
version: 2,
|
|
314
|
+
phase: "init",
|
|
315
|
+
complete: false,
|
|
316
|
+
pluginEphemeralKey: pluginEphemeralKey.publicKeyB64,
|
|
317
|
+
nonce,
|
|
318
|
+
timestamp,
|
|
319
|
+
serverTimestamp: now,
|
|
320
|
+
challengeExpiresAt,
|
|
321
|
+
expiresAt: sessionExpiresAt,
|
|
322
|
+
pluginSignature,
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// soyeht.security.handshake.finish — step 2 of the authenticated X3DH
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
export function handleSecurityHandshakeFinish(
|
|
332
|
+
api: OpenClawPluginApi,
|
|
333
|
+
v2deps: SecurityV2Deps,
|
|
334
|
+
): GatewayRequestHandler {
|
|
335
|
+
return async ({ params, respond }) => {
|
|
336
|
+
if (!v2deps.ready) {
|
|
337
|
+
respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (!v2deps.identity) {
|
|
341
|
+
respond(false, undefined, { code: "NO_IDENTITY", message: "Identity not initialized" });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const accountId = normalizeAccountId(params["accountId"] as string | undefined);
|
|
346
|
+
const nonce = params["nonce"] as string | undefined;
|
|
347
|
+
const appSignature = params["appSignature"] as string | undefined;
|
|
348
|
+
if (!nonce || !appSignature) {
|
|
349
|
+
respond(false, undefined, {
|
|
350
|
+
code: "INVALID_PARAMS",
|
|
351
|
+
message: "Missing required params: accountId, nonce, appSignature",
|
|
352
|
+
});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const cfg = await api.runtime.config.loadConfig();
|
|
357
|
+
const account = resolveSoyehtAccount(cfg, accountId);
|
|
358
|
+
const { allowed } = v2deps.rateLimiter.check(`handshake:finish:${accountId}`, account.security.rateLimit);
|
|
359
|
+
if (!allowed) {
|
|
360
|
+
respond(false, undefined, { code: "RATE_LIMITED", message: "Too many requests" });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const pendingKey = buildPendingHandshakeKey(accountId, nonce);
|
|
365
|
+
const pending = v2deps.pendingHandshakes.get(pendingKey);
|
|
366
|
+
if (!pending) {
|
|
367
|
+
respond(false, undefined, {
|
|
368
|
+
code: "HANDSHAKE_NOT_FOUND",
|
|
369
|
+
message: "No pending handshake found. Start a new handshake first.",
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (pending.challengeExpiresAt <= Date.now()) {
|
|
375
|
+
v2deps.pendingHandshakes.delete(pendingKey);
|
|
376
|
+
respond(false, undefined, {
|
|
377
|
+
code: "HANDSHAKE_EXPIRED",
|
|
378
|
+
message: "Pending handshake expired. Start a new handshake first.",
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const peer = v2deps.peers.get(accountId);
|
|
384
|
+
if (!peer) {
|
|
385
|
+
respond(false, undefined, {
|
|
386
|
+
code: "PEER_NOT_FOUND",
|
|
387
|
+
message: "Peer not registered. Complete QR pairing first.",
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let appIdentityPub;
|
|
393
|
+
try {
|
|
394
|
+
appIdentityPub = importEd25519PublicKey(peer.identityKeyB64);
|
|
395
|
+
} catch {
|
|
396
|
+
respond(false, undefined, {
|
|
397
|
+
code: "INVALID_PEER_KEY",
|
|
398
|
+
message: "Stored peer identity key is invalid",
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!verifyHandshakeTranscript(appIdentityPub, pending.transcript, appSignature)) {
|
|
404
|
+
respond(false, undefined, { code: "INVALID_SIGNATURE", message: "App signature verification failed" });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let appEphemeralPub;
|
|
409
|
+
try {
|
|
410
|
+
appEphemeralPub = importX25519PublicKey(pending.appEphemeralKey);
|
|
411
|
+
} catch {
|
|
412
|
+
v2deps.pendingHandshakes.delete(pendingKey);
|
|
413
|
+
respond(false, undefined, { code: "INVALID_KEY", message: "Stored appEphemeralKey is invalid" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let peerStaticDhPub;
|
|
418
|
+
try {
|
|
419
|
+
peerStaticDhPub = importX25519PublicKey(peer.dhKeyB64);
|
|
420
|
+
} catch {
|
|
421
|
+
respond(false, undefined, {
|
|
422
|
+
code: "INVALID_PEER_KEY",
|
|
423
|
+
message: "Stored peer DH key is invalid",
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const x3dhResult = await performX3DH({
|
|
429
|
+
myStaticDhKey: v2deps.identity.dhKey,
|
|
430
|
+
myEphemeralKey: pending.pluginEphemeralKey,
|
|
431
|
+
peerStaticDhPub,
|
|
432
|
+
peerEphemeralPub: appEphemeralPub,
|
|
433
|
+
nonce,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
clearExistingSession(v2deps, accountId);
|
|
437
|
+
|
|
438
|
+
const session: RatchetState = {
|
|
439
|
+
accountId,
|
|
440
|
+
rootKey: x3dhResult.rootKey,
|
|
441
|
+
sending: { chainKey: x3dhResult.sendChainKey, counter: 0 },
|
|
442
|
+
receiving: { chainKey: x3dhResult.recvChainKey, counter: 0 },
|
|
443
|
+
myCurrentEphDhKey: pending.pluginEphemeralKey,
|
|
444
|
+
peerLastEphDhKeyB64: pending.appEphemeralKey,
|
|
445
|
+
dhRatchetSendCount: 0,
|
|
446
|
+
dhRatchetRecvCount: 0,
|
|
447
|
+
createdAt: Date.now(),
|
|
448
|
+
expiresAt: pending.sessionExpiresAt,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
v2deps.sessions.set(accountId, session);
|
|
452
|
+
v2deps.pendingHandshakes.delete(pendingKey);
|
|
453
|
+
if (v2deps.stateDir) {
|
|
454
|
+
await saveSession(v2deps.stateDir, session).catch((err) => {
|
|
455
|
+
api.logger.error("[soyeht] Failed to persist session", { accountId, err });
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
api.logger.info("[soyeht] V2 handshake completed", { accountId });
|
|
460
|
+
|
|
461
|
+
respond(true, {
|
|
462
|
+
version: 2,
|
|
463
|
+
phase: "finish",
|
|
464
|
+
complete: true,
|
|
465
|
+
expiresAt: pending.sessionExpiresAt,
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// soyeht.security.rotate.v2 — re-establish V2 session
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
export function handleSecurityRotate(
|
|
475
|
+
api: OpenClawPluginApi,
|
|
476
|
+
v2deps: SecurityV2Deps,
|
|
477
|
+
): GatewayRequestHandler {
|
|
478
|
+
return async (ctx) => {
|
|
479
|
+
if (!v2deps.ready) {
|
|
480
|
+
ctx.respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const accountId = normalizeAccountId(ctx.params["accountId"] as string | undefined);
|
|
485
|
+
const cfg = await api.runtime.config.loadConfig();
|
|
486
|
+
const account = resolveSoyehtAccount(cfg, accountId);
|
|
487
|
+
const { allowed } = v2deps.rateLimiter.check(`rotate:${accountId}`, account.security.rateLimit);
|
|
488
|
+
if (!allowed) {
|
|
489
|
+
ctx.respond(false, undefined, { code: "RATE_LIMITED", message: "Too many requests" });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (!v2deps.sessions.has(accountId)) {
|
|
494
|
+
ctx.respond(false, undefined, {
|
|
495
|
+
code: "NO_ACTIVE_SESSION",
|
|
496
|
+
message: "No active V2 session",
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return handleSecurityHandshake(api, v2deps)(ctx);
|
|
502
|
+
};
|
|
503
|
+
}
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Nonce cache — replay protection
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export type NonceCacheOptions = {
|
|
6
|
+
maxAge?: number; // ms, default 600_000 (10 min)
|
|
7
|
+
maxSize?: number; // default 100_000
|
|
8
|
+
pruneIntervalMs?: number; // default 60_000
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type NonceCache = {
|
|
12
|
+
add(nonce: string): boolean; // returns false if already seen
|
|
13
|
+
has(nonce: string): boolean;
|
|
14
|
+
prune(): void;
|
|
15
|
+
dispose(): void;
|
|
16
|
+
readonly size: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createNonceCache(opts: NonceCacheOptions = {}): NonceCache {
|
|
20
|
+
const maxAge = opts.maxAge ?? 600_000;
|
|
21
|
+
const maxSize = opts.maxSize ?? 100_000;
|
|
22
|
+
const pruneIntervalMs = opts.pruneIntervalMs ?? 60_000;
|
|
23
|
+
|
|
24
|
+
const cache = new Map<string, number>(); // nonce → timestamp
|
|
25
|
+
let pruneTimer: ReturnType<typeof setInterval> | undefined;
|
|
26
|
+
|
|
27
|
+
if (pruneIntervalMs > 0) {
|
|
28
|
+
pruneTimer = setInterval(() => prune(), pruneIntervalMs);
|
|
29
|
+
if (pruneTimer && typeof pruneTimer === "object" && "unref" in pruneTimer) {
|
|
30
|
+
pruneTimer.unref();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function prune(): void {
|
|
35
|
+
const cutoff = Date.now() - maxAge;
|
|
36
|
+
for (const [nonce, ts] of cache) {
|
|
37
|
+
if (ts < cutoff) cache.delete(nonce);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function add(nonce: string): boolean {
|
|
42
|
+
if (cache.has(nonce)) return false;
|
|
43
|
+
if (cache.size >= maxSize) prune();
|
|
44
|
+
if (cache.size >= maxSize) {
|
|
45
|
+
// Still full after prune — evict oldest
|
|
46
|
+
const oldest = cache.keys().next().value!;
|
|
47
|
+
cache.delete(oldest);
|
|
48
|
+
}
|
|
49
|
+
cache.set(nonce, Date.now());
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
add,
|
|
55
|
+
has: (nonce) => cache.has(nonce),
|
|
56
|
+
prune,
|
|
57
|
+
dispose() {
|
|
58
|
+
if (pruneTimer) {
|
|
59
|
+
clearInterval(pruneTimer);
|
|
60
|
+
pruneTimer = undefined;
|
|
61
|
+
}
|
|
62
|
+
cache.clear();
|
|
63
|
+
},
|
|
64
|
+
get size() { return cache.size; },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Rate limiter — sliding window per key
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export type RateLimiterOptions = {
|
|
73
|
+
maxRequests?: number; // default 60
|
|
74
|
+
windowMs?: number; // default 60_000 (1 min)
|
|
75
|
+
lockoutMs?: number; // default 300_000 (5 min)
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type RateLimiterCheckOptions = {
|
|
79
|
+
maxRequests?: number;
|
|
80
|
+
windowMs?: number;
|
|
81
|
+
lockoutMs?: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type RateLimiter = {
|
|
85
|
+
check(key: string, opts?: RateLimiterCheckOptions): { allowed: boolean; retryAfterMs?: number };
|
|
86
|
+
prune(): void;
|
|
87
|
+
dispose(): void;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type RateLimiterEntry = {
|
|
91
|
+
timestamps: number[];
|
|
92
|
+
lockedUntil?: number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function createRateLimiter(opts: RateLimiterOptions = {}): RateLimiter {
|
|
96
|
+
const maxRequests = opts.maxRequests ?? 60;
|
|
97
|
+
const windowMs = opts.windowMs ?? 60_000;
|
|
98
|
+
const lockoutMs = opts.lockoutMs ?? 300_000;
|
|
99
|
+
|
|
100
|
+
const entries = new Map<string, RateLimiterEntry>();
|
|
101
|
+
|
|
102
|
+
function check(key: string, overrides?: RateLimiterCheckOptions): { allowed: boolean; retryAfterMs?: number } {
|
|
103
|
+
const effectiveMax = overrides?.maxRequests ?? maxRequests;
|
|
104
|
+
const effectiveWindow = overrides?.windowMs ?? windowMs;
|
|
105
|
+
const effectiveLockout = overrides?.lockoutMs ?? lockoutMs;
|
|
106
|
+
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
let entry = entries.get(key);
|
|
109
|
+
|
|
110
|
+
if (entry?.lockedUntil && now < entry.lockedUntil) {
|
|
111
|
+
return { allowed: false, retryAfterMs: entry.lockedUntil - now };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!entry) {
|
|
115
|
+
entry = { timestamps: [] };
|
|
116
|
+
entries.set(key, entry);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Clear lockout if expired
|
|
120
|
+
if (entry.lockedUntil && now >= entry.lockedUntil) {
|
|
121
|
+
entry.lockedUntil = undefined;
|
|
122
|
+
entry.timestamps = [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Remove timestamps outside window
|
|
126
|
+
const cutoff = now - effectiveWindow;
|
|
127
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
128
|
+
|
|
129
|
+
if (entry.timestamps.length >= effectiveMax) {
|
|
130
|
+
entry.lockedUntil = now + effectiveLockout;
|
|
131
|
+
return { allowed: false, retryAfterMs: effectiveLockout };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
entry.timestamps.push(now);
|
|
135
|
+
return { allowed: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function prune(): void {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
for (const [key, entry] of entries) {
|
|
141
|
+
if (entry.lockedUntil && now >= entry.lockedUntil) {
|
|
142
|
+
entries.delete(key);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const cutoff = now - windowMs;
|
|
146
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
147
|
+
if (entry.timestamps.length === 0 && !entry.lockedUntil) {
|
|
148
|
+
entries.delete(key);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
check,
|
|
155
|
+
prune,
|
|
156
|
+
dispose() { entries.clear(); },
|
|
157
|
+
};
|
|
158
|
+
}
|