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 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
- const pubKeyB64 = bufToBase64Url(pubKeyRaw);
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 = { ws: null, reconnectTimer: null, pingTimer: null, statusSink: null, gatewayCtx: null, e2eSessions: new Map() };
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
- const sessionE2E = replySessionKey ? state.e2eSessions.get(replySessionKey) : undefined;
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
- let outMsg: string;
357
- if (sessionE2E?.ready) {
358
- const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
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 handshakeMsg = await e2eInit(sessionE2E);
589
- // sessionKey를 포함시켜 relay가 올바른 app으로 라우팅하도록 함
590
- const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
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 handshakeMsg = await e2eInit(sessionE2E);
612
- const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
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
- // Encrypt with the per-session key if handshake is complete.
776
- // The outer envelope must carry sessionKey so the relay can route
777
- // the message to the correct app connection.
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}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-app",
3
3
  "name": "OpenClaw App",
4
- "version": "1.0.2",
4
+ "version": "1.0.4",
5
5
  "description": "Mobile app channel for OpenClaw — chat via the OpenClaw Mobile app through a Cloudflare Worker relay.",
6
6
  "channels": [
7
7
  "openclaw-app"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-app",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "OpenClaw Mobile channel plugin — relay bridge for the OpenClaw Mobile app",
5
5
  "main": "index.ts",
6
6
  "type": "module",