openclaw-app 1.0.2 → 1.0.3
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 +189 -30
- 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({
|
|
@@ -575,6 +663,10 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
575
663
|
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
|
|
576
664
|
return;
|
|
577
665
|
}
|
|
666
|
+
if (!state.relayToken) {
|
|
667
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Relay token missing, cannot authenticate handshake`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
578
670
|
// Always restart E2E when peer_joined arrives — the app may have
|
|
579
671
|
// reconnected with a fresh _e2eReadyCompleter and is waiting for a
|
|
580
672
|
// new handshake even if we still hold an old session state.
|
|
@@ -585,9 +677,24 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
585
677
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
|
|
586
678
|
const sessionE2E = makeE2EState();
|
|
587
679
|
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
const
|
|
680
|
+
const pubkey = await e2eInit(sessionE2E);
|
|
681
|
+
const ts = Date.now();
|
|
682
|
+
const mac = await buildHandshakeMac(
|
|
683
|
+
state.relayToken,
|
|
684
|
+
"plugin",
|
|
685
|
+
sessionKey,
|
|
686
|
+
pubkey,
|
|
687
|
+
ts,
|
|
688
|
+
HANDSHAKE_AUTH_VERSION
|
|
689
|
+
);
|
|
690
|
+
const handshakeWithSession = JSON.stringify({
|
|
691
|
+
type: "handshake",
|
|
692
|
+
v: HANDSHAKE_AUTH_VERSION,
|
|
693
|
+
sessionKey,
|
|
694
|
+
pubkey,
|
|
695
|
+
ts,
|
|
696
|
+
mac,
|
|
697
|
+
});
|
|
591
698
|
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
592
699
|
state.ws.send(handshakeWithSession);
|
|
593
700
|
}
|
|
@@ -598,8 +705,36 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
598
705
|
if (msg.type === "handshake") {
|
|
599
706
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
600
707
|
const peerPubKey = msg.pubkey as string | undefined;
|
|
601
|
-
|
|
602
|
-
|
|
708
|
+
const peerMac = msg.mac as string | undefined;
|
|
709
|
+
const version = (msg.v as string | undefined) ?? HANDSHAKE_AUTH_VERSION;
|
|
710
|
+
const peerTs = parseHandshakeTs(msg.ts);
|
|
711
|
+
if (!sessionKey || !peerPubKey || !peerMac || peerTs == null) {
|
|
712
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing auth fields`);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (version !== HANDSHAKE_AUTH_VERSION) {
|
|
716
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Unsupported handshake version: ${version}`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (!isHandshakeTsFresh(peerTs)) {
|
|
720
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake timestamp out of window, dropping`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (!state.relayToken) {
|
|
724
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Relay token missing, cannot verify handshake`);
|
|
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`);
|
|
603
738
|
return;
|
|
604
739
|
}
|
|
605
740
|
let sessionE2E = state.e2eSessions.get(sessionKey);
|
|
@@ -608,8 +743,24 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
608
743
|
// 새 세션을 만들고 plugin의 pubkey를 먼저 전송한 후 ECDH 완성
|
|
609
744
|
sessionE2E = makeE2EState();
|
|
610
745
|
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
611
|
-
const
|
|
612
|
-
const
|
|
746
|
+
const pubkey = await e2eInit(sessionE2E);
|
|
747
|
+
const ts = Date.now();
|
|
748
|
+
const mac = await buildHandshakeMac(
|
|
749
|
+
state.relayToken,
|
|
750
|
+
"plugin",
|
|
751
|
+
sessionKey,
|
|
752
|
+
pubkey,
|
|
753
|
+
ts,
|
|
754
|
+
HANDSHAKE_AUTH_VERSION
|
|
755
|
+
);
|
|
756
|
+
const handshakeWithSession = JSON.stringify({
|
|
757
|
+
type: "handshake",
|
|
758
|
+
v: HANDSHAKE_AUTH_VERSION,
|
|
759
|
+
sessionKey,
|
|
760
|
+
pubkey,
|
|
761
|
+
ts,
|
|
762
|
+
mac,
|
|
763
|
+
});
|
|
613
764
|
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
614
765
|
state.ws.send(handshakeWithSession);
|
|
615
766
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} — sent handshake (reactive, no prior peer_joined)`);
|
|
@@ -650,6 +801,16 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
650
801
|
}
|
|
651
802
|
|
|
652
803
|
// Plaintext message (no E2E or during handshake)
|
|
804
|
+
if (msg.type === "message" || msg.type === "delta" || msg.type === "final" || msg.type === "abort") {
|
|
805
|
+
const sessionKey = msg.sessionKey as string | undefined;
|
|
806
|
+
const sessionE2E = sessionKey ? state.e2eSessions.get(sessionKey) : undefined;
|
|
807
|
+
if (!sessionE2E?.ready) {
|
|
808
|
+
ctx.log?.warn?.(
|
|
809
|
+
`[${CHANNEL_ID}] [${accountId}] [E2E] Dropping plaintext ${msg.type} (session not encrypted yet)`
|
|
810
|
+
);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
653
814
|
await handleInbound(ctx, accountId, msg);
|
|
654
815
|
}
|
|
655
816
|
|
|
@@ -764,6 +925,12 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
764
925
|
// The relay routes plugin→app by sessionKey in the JSON payload
|
|
765
926
|
const replySessionKey = appSessionKey ?? sessionKey;
|
|
766
927
|
const sessionE2E = relayState.e2eSessions.get(replySessionKey);
|
|
928
|
+
if (!sessionE2E?.ready) {
|
|
929
|
+
ctx.log?.warn?.(
|
|
930
|
+
`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: session ${replySessionKey} not ready`
|
|
931
|
+
);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
767
934
|
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
768
935
|
if (!chunk) continue;
|
|
769
936
|
const plainMsg = JSON.stringify({
|
|
@@ -772,17 +939,9 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
772
939
|
content: chunk,
|
|
773
940
|
sessionKey: replySessionKey,
|
|
774
941
|
});
|
|
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
|
-
}
|
|
942
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
943
|
+
encrypted.sessionKey = replySessionKey;
|
|
944
|
+
const outMsg = JSON.stringify(encrypted);
|
|
786
945
|
relayState.ws.send(outMsg);
|
|
787
946
|
}
|
|
788
947
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
|
package/openclaw.plugin.json
CHANGED