openclaw-app 1.0.2 → 1.0.4
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/index.ts +191 -28
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -61,8 +61,7 @@ async function e2eInit(state: E2EState): Promise<string> {
|
|
|
61
61
|
["deriveKey"]
|
|
62
62
|
);
|
|
63
63
|
const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
|
|
64
|
-
|
|
65
|
-
return JSON.stringify({ type: "handshake", pubkey: pubKeyB64 });
|
|
64
|
+
return bufToBase64Url(pubKeyRaw);
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promise<void> {
|
|
@@ -150,6 +149,84 @@ function base64UrlToBuf(b64: string): Uint8Array {
|
|
|
150
149
|
return bytes;
|
|
151
150
|
}
|
|
152
151
|
|
|
152
|
+
const HANDSHAKE_AUTH_VERSION = "v1";
|
|
153
|
+
const HANDSHAKE_AUTH_CONTEXT = "openclaw-hs-auth-v1";
|
|
154
|
+
const HANDSHAKE_MAX_SKEW_MS = 2 * 60 * 1000;
|
|
155
|
+
|
|
156
|
+
function canonicalHandshakePayload(
|
|
157
|
+
version: string,
|
|
158
|
+
role: "plugin" | "app",
|
|
159
|
+
sessionKey: string,
|
|
160
|
+
pubkey: string,
|
|
161
|
+
ts: number
|
|
162
|
+
): string {
|
|
163
|
+
return [HANDSHAKE_AUTH_CONTEXT, version, role, sessionKey, String(ts), pubkey].join("|");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function buildHandshakeMac(
|
|
167
|
+
token: string,
|
|
168
|
+
role: "plugin" | "app",
|
|
169
|
+
sessionKey: string,
|
|
170
|
+
pubkey: string,
|
|
171
|
+
ts: number,
|
|
172
|
+
version = HANDSHAKE_AUTH_VERSION,
|
|
173
|
+
): Promise<string> {
|
|
174
|
+
const key = await crypto.subtle.importKey(
|
|
175
|
+
"raw",
|
|
176
|
+
new TextEncoder().encode(token),
|
|
177
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
178
|
+
false,
|
|
179
|
+
["sign"]
|
|
180
|
+
);
|
|
181
|
+
const payload = new TextEncoder().encode(
|
|
182
|
+
canonicalHandshakePayload(version, role, sessionKey, pubkey, ts)
|
|
183
|
+
);
|
|
184
|
+
const mac = await crypto.subtle.sign("HMAC", key, payload);
|
|
185
|
+
return bufToBase64Url(mac);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
189
|
+
const aa = new TextEncoder().encode(a);
|
|
190
|
+
const bb = new TextEncoder().encode(b);
|
|
191
|
+
if (aa.length !== bb.length) return false;
|
|
192
|
+
let diff = 0;
|
|
193
|
+
for (let i = 0; i < aa.length; i++) diff |= aa[i] ^ bb[i];
|
|
194
|
+
return diff === 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseHandshakeTs(raw: unknown): number | null {
|
|
198
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return Math.trunc(raw);
|
|
199
|
+
if (typeof raw === "string") {
|
|
200
|
+
const n = Number(raw);
|
|
201
|
+
if (Number.isFinite(n)) return Math.trunc(n);
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isHandshakeTsFresh(ts: number): boolean {
|
|
207
|
+
return Math.abs(Date.now() - ts) <= HANDSHAKE_MAX_SKEW_MS;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function verifyHandshakeMac(
|
|
211
|
+
token: string,
|
|
212
|
+
role: "plugin" | "app",
|
|
213
|
+
sessionKey: string,
|
|
214
|
+
pubkey: string,
|
|
215
|
+
ts: number,
|
|
216
|
+
mac: string,
|
|
217
|
+
version: string,
|
|
218
|
+
): Promise<boolean> {
|
|
219
|
+
const expected = await buildHandshakeMac(
|
|
220
|
+
token,
|
|
221
|
+
role,
|
|
222
|
+
sessionKey,
|
|
223
|
+
pubkey,
|
|
224
|
+
ts,
|
|
225
|
+
version
|
|
226
|
+
);
|
|
227
|
+
return constantTimeEquals(mac, expected);
|
|
228
|
+
}
|
|
229
|
+
|
|
153
230
|
// ── Relay state (per account) ────────────────────────────────────────────────
|
|
154
231
|
|
|
155
232
|
interface RelayState {
|
|
@@ -164,6 +241,7 @@ interface RelayState {
|
|
|
164
241
|
* so multiple users can be active simultaneously without key collisions.
|
|
165
242
|
*/
|
|
166
243
|
e2eSessions: Map<string, E2EState>;
|
|
244
|
+
relayToken: string;
|
|
167
245
|
}
|
|
168
246
|
|
|
169
247
|
const relayStates = new Map<string, RelayState>();
|
|
@@ -176,7 +254,15 @@ const CHANNEL_ID = "openclaw-app";
|
|
|
176
254
|
function getRelayState(accountId: string): RelayState {
|
|
177
255
|
let state = relayStates.get(accountId);
|
|
178
256
|
if (!state) {
|
|
179
|
-
state = {
|
|
257
|
+
state = {
|
|
258
|
+
ws: null,
|
|
259
|
+
reconnectTimer: null,
|
|
260
|
+
pingTimer: null,
|
|
261
|
+
statusSink: null,
|
|
262
|
+
gatewayCtx: null,
|
|
263
|
+
e2eSessions: new Map(),
|
|
264
|
+
relayToken: "",
|
|
265
|
+
};
|
|
180
266
|
relayStates.set(accountId, state);
|
|
181
267
|
}
|
|
182
268
|
return state;
|
|
@@ -346,21 +432,22 @@ const channel = {
|
|
|
346
432
|
}
|
|
347
433
|
|
|
348
434
|
const replySessionKey = session?.key ?? null;
|
|
349
|
-
|
|
435
|
+
if (!replySessionKey) {
|
|
436
|
+
return { ok: false, error: "missing session key" };
|
|
437
|
+
}
|
|
438
|
+
const sessionE2E = state.e2eSessions.get(replySessionKey);
|
|
439
|
+
if (!sessionE2E?.ready) {
|
|
440
|
+
return { ok: false, error: `e2e not ready for session ${replySessionKey}` };
|
|
441
|
+
}
|
|
350
442
|
const plainMsg = JSON.stringify({
|
|
351
443
|
type: "message",
|
|
352
444
|
role: "assistant",
|
|
353
445
|
content: outText,
|
|
354
446
|
sessionKey: replySessionKey,
|
|
355
447
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
encrypted.sessionKey = replySessionKey;
|
|
360
|
-
outMsg = JSON.stringify(encrypted);
|
|
361
|
-
} else {
|
|
362
|
-
outMsg = plainMsg;
|
|
363
|
-
}
|
|
448
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
449
|
+
encrypted.sessionKey = replySessionKey;
|
|
450
|
+
const outMsg = JSON.stringify(encrypted);
|
|
364
451
|
state.ws.send(outMsg);
|
|
365
452
|
|
|
366
453
|
return {
|
|
@@ -475,6 +562,7 @@ function cleanupRelay(state: RelayState) {
|
|
|
475
562
|
function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
476
563
|
const accountId = account.accountId;
|
|
477
564
|
const state = getRelayState(accountId);
|
|
565
|
+
state.relayToken = account.relayToken ?? "";
|
|
478
566
|
|
|
479
567
|
const base = account.relayUrl.replace(/\/$/, "");
|
|
480
568
|
const params = new URLSearchParams({
|
|
@@ -585,9 +673,27 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
585
673
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
|
|
586
674
|
const sessionE2E = makeE2EState();
|
|
587
675
|
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
676
|
+
const pubkey = await e2eInit(sessionE2E);
|
|
677
|
+
const handshakePayload: Record<string, unknown> = {
|
|
678
|
+
type: "handshake",
|
|
679
|
+
sessionKey,
|
|
680
|
+
pubkey,
|
|
681
|
+
};
|
|
682
|
+
if (state.relayToken) {
|
|
683
|
+
const ts = Date.now();
|
|
684
|
+
const mac = await buildHandshakeMac(
|
|
685
|
+
state.relayToken,
|
|
686
|
+
"plugin",
|
|
687
|
+
sessionKey,
|
|
688
|
+
pubkey,
|
|
689
|
+
ts,
|
|
690
|
+
HANDSHAKE_AUTH_VERSION
|
|
691
|
+
);
|
|
692
|
+
handshakePayload.v = HANDSHAKE_AUTH_VERSION;
|
|
693
|
+
handshakePayload.ts = ts;
|
|
694
|
+
handshakePayload.mac = mac;
|
|
695
|
+
}
|
|
696
|
+
const handshakeWithSession = JSON.stringify(handshakePayload);
|
|
591
697
|
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
592
698
|
state.ws.send(handshakeWithSession);
|
|
593
699
|
}
|
|
@@ -598,18 +704,67 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
598
704
|
if (msg.type === "handshake") {
|
|
599
705
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
600
706
|
const peerPubKey = msg.pubkey as string | undefined;
|
|
707
|
+
const peerMac = msg.mac as string | undefined;
|
|
708
|
+
const version = (msg.v as string | undefined) ?? HANDSHAKE_AUTH_VERSION;
|
|
709
|
+
const peerTs = parseHandshakeTs(msg.ts);
|
|
601
710
|
if (!sessionKey || !peerPubKey) {
|
|
602
711
|
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
|
|
603
712
|
return;
|
|
604
713
|
}
|
|
714
|
+
if (state.relayToken) {
|
|
715
|
+
if (!peerMac || peerTs == null) {
|
|
716
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing auth fields`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (version !== HANDSHAKE_AUTH_VERSION) {
|
|
720
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Unsupported handshake version: ${version}`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (!isHandshakeTsFresh(peerTs)) {
|
|
724
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake timestamp out of window, dropping`);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const verified = await verifyHandshakeMac(
|
|
728
|
+
state.relayToken,
|
|
729
|
+
"app",
|
|
730
|
+
sessionKey,
|
|
731
|
+
peerPubKey,
|
|
732
|
+
peerTs,
|
|
733
|
+
peerMac,
|
|
734
|
+
version
|
|
735
|
+
);
|
|
736
|
+
if (!verified) {
|
|
737
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake MAC verification failed, dropping`);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
605
741
|
let sessionE2E = state.e2eSessions.get(sessionKey);
|
|
606
742
|
if (!sessionE2E) {
|
|
607
743
|
// peer_joined 없이 app이 먼저 handshake를 보낸 경우 (예: plugin 재연결)
|
|
608
744
|
// 새 세션을 만들고 plugin의 pubkey를 먼저 전송한 후 ECDH 완성
|
|
609
745
|
sessionE2E = makeE2EState();
|
|
610
746
|
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
611
|
-
const
|
|
612
|
-
const
|
|
747
|
+
const pubkey = await e2eInit(sessionE2E);
|
|
748
|
+
const handshakePayload: Record<string, unknown> = {
|
|
749
|
+
type: "handshake",
|
|
750
|
+
sessionKey,
|
|
751
|
+
pubkey,
|
|
752
|
+
};
|
|
753
|
+
if (state.relayToken) {
|
|
754
|
+
const ts = Date.now();
|
|
755
|
+
const mac = await buildHandshakeMac(
|
|
756
|
+
state.relayToken,
|
|
757
|
+
"plugin",
|
|
758
|
+
sessionKey,
|
|
759
|
+
pubkey,
|
|
760
|
+
ts,
|
|
761
|
+
HANDSHAKE_AUTH_VERSION
|
|
762
|
+
);
|
|
763
|
+
handshakePayload.v = HANDSHAKE_AUTH_VERSION;
|
|
764
|
+
handshakePayload.ts = ts;
|
|
765
|
+
handshakePayload.mac = mac;
|
|
766
|
+
}
|
|
767
|
+
const handshakeWithSession = JSON.stringify(handshakePayload);
|
|
613
768
|
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
614
769
|
state.ws.send(handshakeWithSession);
|
|
615
770
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} — sent handshake (reactive, no prior peer_joined)`);
|
|
@@ -650,6 +805,16 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
650
805
|
}
|
|
651
806
|
|
|
652
807
|
// Plaintext message (no E2E or during handshake)
|
|
808
|
+
if (msg.type === "message" || msg.type === "delta" || msg.type === "final" || msg.type === "abort") {
|
|
809
|
+
const sessionKey = msg.sessionKey as string | undefined;
|
|
810
|
+
const sessionE2E = sessionKey ? state.e2eSessions.get(sessionKey) : undefined;
|
|
811
|
+
if (!sessionE2E?.ready) {
|
|
812
|
+
ctx.log?.warn?.(
|
|
813
|
+
`[${CHANNEL_ID}] [${accountId}] [E2E] Dropping plaintext ${msg.type} (session not encrypted yet)`
|
|
814
|
+
);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
653
818
|
await handleInbound(ctx, accountId, msg);
|
|
654
819
|
}
|
|
655
820
|
|
|
@@ -764,6 +929,12 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
764
929
|
// The relay routes plugin→app by sessionKey in the JSON payload
|
|
765
930
|
const replySessionKey = appSessionKey ?? sessionKey;
|
|
766
931
|
const sessionE2E = relayState.e2eSessions.get(replySessionKey);
|
|
932
|
+
if (!sessionE2E?.ready) {
|
|
933
|
+
ctx.log?.warn?.(
|
|
934
|
+
`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: session ${replySessionKey} not ready`
|
|
935
|
+
);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
767
938
|
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
768
939
|
if (!chunk) continue;
|
|
769
940
|
const plainMsg = JSON.stringify({
|
|
@@ -772,17 +943,9 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
772
943
|
content: chunk,
|
|
773
944
|
sessionKey: replySessionKey,
|
|
774
945
|
});
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
let outMsg: string;
|
|
779
|
-
if (sessionE2E?.ready) {
|
|
780
|
-
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
781
|
-
encrypted.sessionKey = replySessionKey;
|
|
782
|
-
outMsg = JSON.stringify(encrypted);
|
|
783
|
-
} else {
|
|
784
|
-
outMsg = plainMsg;
|
|
785
|
-
}
|
|
946
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
947
|
+
encrypted.sessionKey = replySessionKey;
|
|
948
|
+
const outMsg = JSON.stringify(encrypted);
|
|
786
949
|
relayState.ws.send(outMsg);
|
|
787
950
|
}
|
|
788
951
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
|
package/openclaw.plugin.json
CHANGED