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 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({
@@ -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 handshakeMsg = await e2eInit(sessionE2E);
589
- // sessionKey를 포함시켜 relay가 올바른 app으로 라우팅하도록 함
590
- const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
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
- if (!sessionKey || !peerPubKey) {
602
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
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 handshakeMsg = await e2eInit(sessionE2E);
612
- const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
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
- // 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
- }
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}`);
@@ -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.3",
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.3",
4
4
  "description": "OpenClaw Mobile channel plugin — relay bridge for the OpenClaw Mobile app",
5
5
  "main": "index.ts",
6
6
  "type": "module",