diff --git a/CHANGELOG.md b/CHANGELOG.md index f4bbfa997..b83b594b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Breaking +- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. - **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. ### Fixes diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 091e73553..0f49541da 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -178,7 +178,7 @@ class GatewaySession( private val connectDeferred = CompletableDeferred() private val closedDeferred = CompletableDeferred() private val isClosed = AtomicBoolean(false) - private val connectNonceDeferred = CompletableDeferred() + private val connectNonceDeferred = CompletableDeferred() private val client: OkHttpClient = buildClient() private var socket: WebSocket? = null private val loggerTag = "OpenClawGateway" @@ -296,7 +296,7 @@ class GatewaySession( } } - private suspend fun sendConnect(connectNonce: String?) { + private suspend fun sendConnect(connectNonce: String) { val identity = identityStore.loadOrCreate() val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() @@ -332,7 +332,7 @@ class GatewaySession( private fun buildConnectParams( identity: DeviceIdentity, - connectNonce: String?, + connectNonce: String, authToken: String, authPassword: String?, ): JsonObject { @@ -385,9 +385,7 @@ class GatewaySession( put("publicKey", JsonPrimitive(publicKey)) put("signature", JsonPrimitive(signature)) put("signedAt", JsonPrimitive(signedAtMs)) - if (!connectNonce.isNullOrBlank()) { - put("nonce", JsonPrimitive(connectNonce)) - } + put("nonce", JsonPrimitive(connectNonce)) } } else { null @@ -447,8 +445,8 @@ class GatewaySession( frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() if (event == "connect.challenge") { val nonce = extractConnectNonce(payloadJson) - if (!connectNonceDeferred.isCompleted) { - connectNonceDeferred.complete(nonce) + if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) { + connectNonceDeferred.complete(nonce.trim()) } return } @@ -459,12 +457,11 @@ class GatewaySession( onEvent(event, payloadJson) } - private suspend fun awaitConnectNonce(): String? { - if (isLoopbackHost(endpoint.host)) return null + private suspend fun awaitConnectNonce(): String { return try { withTimeout(2_000) { connectNonceDeferred.await() } - } catch (_: Throwable) { - null + } catch (err: Throwable) { + throw IllegalStateException("connect challenge timeout", err) } } @@ -595,14 +592,13 @@ class GatewaySession( scopes: List, signedAtMs: Long, token: String?, - nonce: String?, + nonce: String, ): String { val scopeString = scopes.joinToString(",") val authToken = token.orEmpty() - val version = if (nonce.isNullOrBlank()) "v1" else "v2" val parts = mutableListOf( - version, + "v2", deviceId, clientId, clientMode, @@ -610,10 +606,8 @@ class GatewaySession( scopeString, signedAtMs.toString(), authToken, + nonce, ) - if (!nonce.isNullOrBlank()) { - parts.add(nonce) - } return parts.joinToString("|") } diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 0a73fc210..2d36bac3c 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -281,8 +281,8 @@ actor GatewayWizardClient { let identity = DeviceIdentityStore.loadOrCreate() let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", + let payloadParts = [ + "v2", identity.deviceId, clientId, clientMode, @@ -290,23 +290,19 @@ actor GatewayWizardClient { scopesValue, String(signedAtMs), self.token ?? "", + connectNonce, ] - if let connectNonce { - payloadParts.append(connectNonce) - } let payload = payloadParts.joined(separator: "|") if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { - var device: [String: ProtoAnyCodable] = [ + let device: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(identity.deviceId), "publicKey": ProtoAnyCodable(publicKey), "signature": ProtoAnyCodable(signature), "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), ] - if let connectNonce { - device["nonce"] = ProtoAnyCodable(connectNonce) - } params["device"] = ProtoAnyCodable(device) } @@ -333,29 +329,24 @@ actor GatewayWizardClient { } } - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { - while true { - let message = try await task.receive() - let frame = try await self.decodeFrame(message) - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String - { - return nonce - } - } + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { + while true { + let message = try await task.receive() + let frame = try await self.decodeFrame(message) + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce } - }) - } catch { - if error is ConnectChallengeError { return nil } - throw error - } + } + }) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index f6aac2697..1aa1b5ae3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -146,8 +146,8 @@ public actor GatewayChannelActor { private var lastAuthSource: GatewayAuthSource = .none private let decoder = JSONDecoder() private let encoder = JSONEncoder() - // Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event, - // and we must include the nonce once the gateway requires v2 signing. + // Remote gateways (tailscale/wan) can take longer to deliver connect.challenge. + // Connect now requires this nonce before we send device-auth. private let connectTimeoutSeconds: Double = 12 private let connectChallengeTimeoutSeconds: Double = 6.0 // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, @@ -391,8 +391,8 @@ public actor GatewayChannelActor { let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let connectNonce = try await self.waitForConnectChallenge() let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", + let payloadParts = [ + "v2", identity?.deviceId ?? "", clientId, clientMode, @@ -400,23 +400,19 @@ public actor GatewayChannelActor { scopesValue, String(signedAtMs), authToken ?? "", + connectNonce, ] - if let connectNonce { - payloadParts.append(connectNonce) - } let payload = payloadParts.joined(separator: "|") if includeDeviceIdentity, let identity { if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { - var device: [String: ProtoAnyCodable] = [ + let device: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(identity.deviceId), "publicKey": ProtoAnyCodable(publicKey), "signature": ProtoAnyCodable(signature), "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), ] - if let connectNonce { - device["nonce"] = ProtoAnyCodable(connectNonce) - } params["device"] = ProtoAnyCodable(device) } } @@ -545,33 +541,26 @@ public actor GatewayChannelActor { } } - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { [weak self] in - guard let self else { return nil } - while true { - let msg = try await task.receive() - guard let data = self.decodeMessageData(msg) else { continue } - guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String { - return nonce - } - } + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { [weak self] in + guard let self else { throw ConnectChallengeError.timeout } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce } - }) - } catch { - if error is ConnectChallengeError { - self.logger.warning("gateway connect challenge timed out") - return nil - } - throw error - } + } + }) } private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index de9582c71..75addf3fa 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -97,8 +97,8 @@ sequenceDiagram for subsequent connects. - **Local** connects (loopback or the gateway host’s own tailnet address) can be auto‑approved to keep same‑host UX smooth. -- **Non‑local** connects must sign the `connect.challenge` nonce and require - explicit approval. +- All connects must sign the `connect.challenge` nonce. +- **Non‑local** connects still require explicit approval. - Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or remote. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index fde213bb1..8bcedbe06 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -206,7 +206,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - All WS clients must include `device` identity during `connect` (operator + node). Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth` is enabled for break-glass use. -- Non-local connections must sign the server-provided `connect.challenge` nonce. +- All connections must sign the server-provided `connect.challenge` nonce. ## TLS + pinning diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 05f91e78b..4e957c6e0 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -223,6 +223,12 @@ export class GatewayClient { if (this.connectSent) { return; } + const nonce = this.connectNonce?.trim() ?? ""; + if (!nonce) { + this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce")); + this.ws?.close(1008, "connect challenge missing nonce"); + return; + } this.connectSent = true; if (this.connectTimer) { clearTimeout(this.connectTimer); @@ -243,7 +249,6 @@ export class GatewayClient { } : undefined; const signedAtMs = Date.now(); - const nonce = this.connectNonce ?? undefined; const scopes = this.opts.scopes ?? ["operator.admin"]; const device = (() => { if (!this.opts.deviceIdentity) { @@ -332,10 +337,13 @@ export class GatewayClient { if (evt.event === "connect.challenge") { const payload = evt.payload as { nonce?: unknown } | undefined; const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; - if (nonce) { - this.connectNonce = nonce; - this.sendConnect(); + if (!nonce || nonce.trim().length === 0) { + this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce")); + this.ws?.close(1008, "connect challenge missing nonce"); + return; } + this.connectNonce = nonce.trim(); + this.sendConnect(); return; } const seq = typeof evt.seq === "number" ? evt.seq : null; @@ -378,16 +386,20 @@ export class GatewayClient { this.connectNonce = null; this.connectSent = false; const rawConnectDelayMs = this.opts.connectDelayMs; - const connectDelayMs = + const connectChallengeTimeoutMs = typeof rawConnectDelayMs === "number" && Number.isFinite(rawConnectDelayMs) - ? Math.max(0, Math.min(5_000, rawConnectDelayMs)) - : 750; + ? Math.max(250, Math.min(10_000, rawConnectDelayMs)) + : 2_000; if (this.connectTimer) { clearTimeout(this.connectTimer); } this.connectTimer = setTimeout(() => { - this.sendConnect(); - }, connectDelayMs); + if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) { + return; + } + this.opts.onConnectError?.(new Error("gateway connect challenge timeout")); + this.ws?.close(1008, "connect challenge timeout"); + }, connectChallengeTimeoutMs); } private scheduleReconnect() { diff --git a/src/gateway/device-auth.ts b/src/gateway/device-auth.ts index 9a70444cd..2e5b9e6fa 100644 --- a/src/gateway/device-auth.ts +++ b/src/gateway/device-auth.ts @@ -6,16 +6,14 @@ export type DeviceAuthPayloadParams = { scopes: string[]; signedAtMs: number; token?: string | null; - nonce?: string | null; - version?: "v1" | "v2"; + nonce: string; }; export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { - const version = params.version ?? (params.nonce ? "v2" : "v1"); const scopes = params.scopes.join(","); const token = params.token ?? ""; - const base = [ - version, + return [ + "v2", params.deviceId, params.clientId, params.clientMode, @@ -23,9 +21,6 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string scopes, String(params.signedAtMs), token, - ]; - if (version === "v2") { - base.push(params.nonce ?? ""); - } - return base.join("|"); + params.nonce, + ].join("|"); } diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index a084e3433..53f8a9484 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -47,7 +47,7 @@ export const ConnectParamsSchema = Type.Object( publicKey: NonEmptyString, signature: NonEmptyString, signedAt: Type.Integer({ minimum: 0 }), - nonce: Type.Optional(NonEmptyString), + nonce: NonEmptyString, }, { additionalProperties: false }, ), diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index de555cca4..20680cb62 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -7,12 +7,14 @@ import { PROTOCOL_VERSION } from "./protocol/index.js"; import { getHandshakeTimeoutMs } from "./server-constants.js"; import { connectReq, + getTrackedConnectChallengeNonce, getFreePort, installGatewayTestHooks, onceMessage, rpcReq, startGatewayServer, startServerWithClient, + trackConnectChallengeNonce, testTailscaleWhois, testState, withGatewayServer, @@ -35,10 +37,26 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined); + trackConnectChallengeNonce(ws); await new Promise((resolve) => ws.once("open", resolve)); return ws; }; +const readConnectChallengeNonce = async (ws: WebSocket) => { + const cached = getTrackedConnectChallengeNonce(ws); + if (cached) { + return cached; + } + const challenge = await onceMessage<{ + type?: string; + event?: string; + payload?: Record | null; + }>(ws, (o) => o.type === "event" && o.event === "connect.challenge"); + const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + return String(nonce); +}; + const openTailscaleWs = async (port: number) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { @@ -50,6 +68,7 @@ const openTailscaleWs = async (port: number) => { "tailscale-user-name": "Peter", }, }); + trackConnectChallengeNonce(ws); await new Promise((resolve) => ws.once("open", resolve)); return ws; }; @@ -132,7 +151,7 @@ async function createSignedDevice(params: { clientId: string; clientMode: string; identityPath?: string; - nonce?: string; + nonce: string; signedAtMs?: number; }) { const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = @@ -434,6 +453,7 @@ describe("gateway server auth/connect", () => { test("does not grant admin when scopes are omitted", async () => { const ws = await openWs(port); const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); const { randomUUID } = await import("node:crypto"); const os = await import("node:os"); @@ -445,6 +465,7 @@ describe("gateway server auth/connect", () => { clientId: GATEWAY_CLIENT_NAMES.TEST, clientMode: GATEWAY_CLIENT_MODES.TEST, identityPath: path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.json`), + nonce, }); const connectRes = await sendRawConnectReq(ws, { @@ -480,12 +501,14 @@ describe("gateway server auth/connect", () => { test("rejects device signature when scopes are omitted but signed with admin", async () => { const ws = await openWs(port); const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); const { device } = await createSignedDevice({ token, scopes: ["operator.admin"], clientId: GATEWAY_CLIENT_NAMES.TEST, clientMode: GATEWAY_CLIENT_MODES.TEST, + nonce, }); const connectRes = await sendRawConnectReq(ws, { @@ -537,15 +560,26 @@ describe("gateway server auth/connect", () => { await new Promise((resolve) => ws.once("close", () => resolve())); }); - test("requires nonce when host is non-local", async () => { + test("requires nonce for device auth", async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { host: "example.com" }, }); await new Promise((resolve) => ws.once("open", resolve)); - const res = await connectReq(ws); + const { device } = await createSignedDevice({ + token: "secret", + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + nonce: "nonce-not-sent", + }); + const { nonce: _nonce, ...deviceWithoutNonce } = device; + const res = await connectReq(ws, { + token: "secret", + device: deviceWithoutNonce, + }); expect(res.ok).toBe(false); - expect(res.error?.message).toBe("device nonce required"); + expect(res.error?.message ?? "").toContain("must have required property 'nonce'"); await new Promise((resolve) => ws.once("close", () => resolve())); }); @@ -836,12 +870,16 @@ describe("gateway server auth/connect", () => { const challenge = await challengePromise; const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; expect(typeof nonce).toBe("string"); + const { randomUUID } = await import("node:crypto"); + const os = await import("node:os"); + const path = await import("node:path"); const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const { device } = await createSignedDevice({ token: "secret", scopes, clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + identityPath: path.join(os.tmpdir(), `openclaw-controlui-device-${randomUUID()}.json`), nonce: String(nonce), }); const res = await connectReq(ws, { @@ -869,12 +907,15 @@ describe("gateway server auth/connect", () => { try { await withGatewayServer(async ({ port }) => { const ws = await openWs(port, { origin: originForPort(port) }); + const challengeNonce = await readConnectChallengeNonce(ws); + expect(challengeNonce).toBeTruthy(); const { device } = await createSignedDevice({ token: "secret", scopes: [], clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, signedAtMs: Date.now() - 60 * 60 * 1000, + nonce: String(challengeNonce), }); const res = await connectReq(ws, { token: "secret", @@ -901,8 +942,7 @@ describe("gateway server auth/connect", () => { ws.close(); - const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws2.once("open", resolve)); + const ws2 = await openWs(port); const res2 = await connectReq(ws2, { token: deviceToken }); expect(res2.ok).toBe(true); @@ -984,7 +1024,7 @@ describe("gateway server auth/connect", () => { platform: "test", mode: GATEWAY_CLIENT_MODES.TEST, }; - const buildDevice = (scopes: string[]) => { + const buildDevice = (scopes: string[], nonce: string) => { const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ deviceId: identity.deviceId, @@ -994,19 +1034,22 @@ describe("gateway server auth/connect", () => { scopes, signedAtMs, token: "secret", + nonce, }); return { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, + nonce, }; }; + const initialNonce = await readConnectChallengeNonce(ws); const initial = await connectReq(ws, { token: "secret", scopes: ["operator.read"], client, - device: buildDevice(["operator.read"]), + device: buildDevice(["operator.read"], initialNonce), }); if (!initial.ok) { await approvePendingPairingIfNeeded(); @@ -1017,13 +1060,13 @@ describe("gateway server auth/connect", () => { ws.close(); - const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws2.once("open", resolve)); + const ws2 = await openWs(port); + const nonce2 = await readConnectChallengeNonce(ws2); const res = await connectReq(ws2, { token: "secret", scopes: ["operator.admin"], client, - device: buildDevice(["operator.admin"]), + device: buildDevice(["operator.admin"], nonce2), }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("pairing required"); @@ -1031,13 +1074,13 @@ describe("gateway server auth/connect", () => { await approvePendingPairingIfNeeded(); ws2.close(); - const ws3 = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws3.once("open", resolve)); + const ws3 = await openWs(port); + const nonce3 = await readConnectChallengeNonce(ws3); const approved = await connectReq(ws3, { token: "secret", scopes: ["operator.admin"], client, - device: buildDevice(["operator.admin"]), + device: buildDevice(["operator.admin"], nonce3), }); expect(approved.ok).toBe(true); paired = await getPairedDevice(identity.deviceId); @@ -1066,7 +1109,7 @@ describe("gateway server auth/connect", () => { platform: "test", mode: GATEWAY_CLIENT_MODES.TEST, }; - const buildDevice = (role: "operator" | "node", scopes: string[], nonce?: string) => { + const buildDevice = (role: "operator" | "node", scopes: string[], nonce: string) => { const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ deviceId: identity.deviceId, @@ -1164,7 +1207,7 @@ describe("gateway server auth/connect", () => { platform: "test", mode: GATEWAY_CLIENT_MODES.TEST, }; - const buildDevice = (scopes: string[]) => { + const buildDevice = (scopes: string[], nonce: string) => { const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ deviceId: identity.deviceId, @@ -1174,20 +1217,23 @@ describe("gateway server auth/connect", () => { scopes, signedAtMs, token: "secret", + nonce, }); return { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, + nonce, }; }; + const initialNonce = await readConnectChallengeNonce(ws); const initial = await connectReq(ws, { token: "secret", scopes: ["operator.admin"], client, - device: buildDevice(["operator.admin"]), + device: buildDevice(["operator.admin"], initialNonce), }); if (!initial.ok) { await approvePendingPairingIfNeeded(); @@ -1195,13 +1241,13 @@ describe("gateway server auth/connect", () => { ws.close(); - const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws2.once("open", resolve)); + const ws2 = await openWs(port); + const nonce2 = await readConnectChallengeNonce(ws2); const res = await connectReq(ws2, { token: "secret", scopes: ["operator.read"], client, - device: buildDevice(["operator.read"]), + device: buildDevice(["operator.read"], nonce2), }); expect(res.ok).toBe(true); ws2.close(); @@ -1214,26 +1260,47 @@ describe("gateway server auth/connect", () => { }); test("allows legacy paired devices missing role/scope metadata", async () => { + const { mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { buildDeviceAuthPayload } = await import("./device-auth.js"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js"); const { writeJsonAtomic } = await import("../infra/json-files.js"); const { getPairedDevice } = await import("../infra/device-pairing.js"); - const { - device, - identity: { deviceId }, - } = await createSignedDevice({ - token: "secret", - scopes: ["operator.read"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - }); + const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-meta-")); + const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const deviceId = identity.deviceId; + const buildDevice = (nonce: string) => { + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId, + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + role: "operator", + scopes: ["operator.read"], + signedAtMs, + token: "secret", + nonce, + }); + return { + id: deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + }; const { server, ws, port, prevToken } = await startServerWithClient("secret"); let ws2: WebSocket | undefined; try { + const initialNonce = await readConnectChallengeNonce(ws); const initial = await connectReq(ws, { token: "secret", scopes: ["operator.read"], client: TEST_OPERATOR_CLIENT, - device, + device: buildDevice(initialNonce), }); if (!initial.ok) { await approvePendingPairingIfNeeded(); @@ -1256,14 +1323,14 @@ describe("gateway server auth/connect", () => { await writeJsonAtomic(pairedPath, paired); ws.close(); - const wsReconnect = new WebSocket(`ws://127.0.0.1:${port}`); + const wsReconnect = await openWs(port); ws2 = wsReconnect; - await new Promise((resolve) => wsReconnect.once("open", resolve)); + const reconnectNonce = await readConnectChallengeNonce(wsReconnect); const reconnect = await connectReq(wsReconnect, { token: "secret", scopes: ["operator.read"], client: TEST_OPERATOR_CLIENT, - device, + device: buildDevice(reconnectNonce), }); expect(reconnect.ok).toBe(true); @@ -1302,7 +1369,7 @@ describe("gateway server auth/connect", () => { platform: "test", mode: GATEWAY_CLIENT_MODES.TEST, }; - const buildDevice = (scopes: string[]) => { + const buildDevice = (scopes: string[], nonce: string) => { const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ deviceId: identity.deviceId, @@ -1312,20 +1379,23 @@ describe("gateway server auth/connect", () => { scopes, signedAtMs, token: "secret", + nonce, }); return { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, + nonce, }; }; + const initialNonce = await readConnectChallengeNonce(ws); const initial = await connectReq(ws, { token: "secret", scopes: ["operator.read"], client, - device: buildDevice(["operator.read"]), + device: buildDevice(["operator.read"], initialNonce), }); if (!initial.ok) { const list = await listDevicePairing(); @@ -1349,14 +1419,14 @@ describe("gateway server auth/connect", () => { delete legacy.scopes; await writeJsonAtomic(pairedPath, paired); - const wsUpgrade = new WebSocket(`ws://127.0.0.1:${port}`); + const wsUpgrade = await openWs(port); ws2 = wsUpgrade; - await new Promise((resolve) => wsUpgrade.once("open", resolve)); + const upgradeNonce = await readConnectChallengeNonce(wsUpgrade); const upgraded = await connectReq(wsUpgrade, { token: "secret", scopes: ["operator.admin"], client, - device: buildDevice(["operator.admin"]), + device: buildDevice(["operator.admin"], upgradeNonce), }); expect(upgraded.ok).toBe(false); expect(upgraded.error?.message ?? "").toContain("pairing required"); @@ -1389,8 +1459,7 @@ describe("gateway server auth/connect", () => { ws.close(); - const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws2.once("open", resolve)); + const ws2 = await openWs(port); const res2 = await connectReq(ws2, { token: deviceToken }); expect(res2.ok).toBe(false); diff --git a/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts b/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts index e72692b1a..f1b3255bc 100644 --- a/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts @@ -13,8 +13,10 @@ import { buildDeviceAuthPayload } from "./device-auth.js"; import { connectReq, installGatewayTestHooks, + onceMessage, rpcReq, startServerWithClient, + trackConnectChallengeNonce, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -78,15 +80,33 @@ describe("node.invoke approval bypass", () => { const connectOperatorWithRetry = async ( scopes: string[], - resolveDevice?: () => NonNullable[1]>["device"], + resolveDevice?: (nonce: string) => NonNullable[1]>["device"], ) => { const connectOnce = async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}`); + trackConnectChallengeNonce(ws); + const challengePromise = resolveDevice + ? onceMessage<{ + type?: string; + event?: string; + payload?: Record | null; + }>(ws, (o) => o.type === "event" && o.event === "connect.challenge") + : null; await new Promise((resolve) => ws.once("open", resolve)); + const nonce = (() => { + if (!challengePromise) { + return Promise.resolve(""); + } + return challengePromise.then((challenge) => { + const value = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof value).toBe("string"); + return String(value); + }); + })(); const res = await connectReq(ws, { token: "secret", scopes, - ...(resolveDevice ? { device: resolveDevice() } : {}), + ...(resolveDevice ? { device: resolveDevice(await nonce) } : {}), }); return { ws, res }; }; @@ -116,22 +136,26 @@ describe("node.invoke approval bypass", () => { const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem); const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw); expect(deviceId).toBeTruthy(); - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: deviceId!, - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - role: "operator", - scopes, - signedAtMs, - token: "secret", + return await connectOperatorWithRetry(scopes, (nonce) => { + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: deviceId!, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes, + signedAtMs, + token: "secret", + nonce, + }); + return { + id: deviceId!, + publicKey: publicKeyRaw, + signature: signDevicePayload(privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; }); - return await connectOperatorWithRetry(scopes, () => ({ - id: deviceId!, - publicKey: publicKeyRaw, - signature: signDevicePayload(privateKeyPem, payload), - signedAt: signedAtMs, - })); }; const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => { diff --git a/src/gateway/server.talk-config.e2e.test.ts b/src/gateway/server.talk-config.e2e.test.ts index 38095c19a..7ab64a612 100644 --- a/src/gateway/server.talk-config.e2e.test.ts +++ b/src/gateway/server.talk-config.e2e.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from "vitest"; -import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js"; +import { + connectOk, + installGatewayTestHooks, + readConnectChallengeNonce, + rpcReq, +} from "./test-helpers.js"; import { withServer } from "./test-with-server.js"; installGatewayTestHooks({ scope: "suite" }); -async function createFreshOperatorDevice(scopes: string[]) { +async function createFreshOperatorDevice(scopes: string[], nonce: string) { const { randomUUID } = await import("node:crypto"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); @@ -24,6 +29,7 @@ async function createFreshOperatorDevice(scopes: string[]) { scopes, signedAtMs, token: "secret", + nonce, }); return { @@ -31,6 +37,7 @@ async function createFreshOperatorDevice(scopes: string[]) { publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, + nonce, }; } @@ -51,10 +58,12 @@ describe("gateway talk.config", () => { }); await withServer(async (ws) => { + const nonce = await readConnectChallengeNonce(ws); + expect(nonce).toBeTruthy(); await connectOk(ws, { token: "secret", scopes: ["operator.read"], - device: await createFreshOperatorDevice(["operator.read"]), + device: await createFreshOperatorDevice(["operator.read"], String(nonce)), }); const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>( ws, @@ -76,10 +85,12 @@ describe("gateway talk.config", () => { }); await withServer(async (ws) => { + const nonce = await readConnectChallengeNonce(ws); + expect(nonce).toBeTruthy(); await connectOk(ws, { token: "secret", scopes: ["operator.read"], - device: await createFreshOperatorDevice(["operator.read"]), + device: await createFreshOperatorDevice(["operator.read"], String(nonce)), }); const res = await rpcReq(ws, "talk.config", { includeSecrets: true }); expect(res.ok).toBe(false); @@ -96,14 +107,15 @@ describe("gateway talk.config", () => { }); await withServer(async (ws) => { + const nonce = await readConnectChallengeNonce(ws); + expect(nonce).toBeTruthy(); await connectOk(ws, { token: "secret", scopes: ["operator.read", "operator.write", "operator.talk.secrets"], - device: await createFreshOperatorDevice([ - "operator.read", - "operator.write", - "operator.talk.secrets", - ]), + device: await createFreshOperatorDevice( + ["operator.read", "operator.write", "operator.talk.secrets"], + String(nonce), + ), }); const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", { includeSecrets: true, diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 57dadbf74..e0b691fec 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -10,7 +10,13 @@ describe("ws connect policy", () => { const bypass = resolveControlUiAuthPolicy({ isControlUi: true, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, - deviceRaw: { id: "dev-1", publicKey: "pk", signature: "sig", signedAt: Date.now() }, + deviceRaw: { + id: "dev-1", + publicKey: "pk", + signature: "sig", + signedAt: Date.now(), + nonce: "nonce-1", + }, }); expect(bypass.allowBypass).toBe(true); expect(bypass.device).toBeNull(); @@ -18,7 +24,13 @@ describe("ws connect policy", () => { const regular = resolveControlUiAuthPolicy({ isControlUi: false, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, - deviceRaw: { id: "dev-2", publicKey: "pk", signature: "sig", signedAt: Date.now() }, + deviceRaw: { + id: "dev-2", + publicKey: "pk", + signature: "sig", + signedAt: Date.now(), + nonce: "nonce-2", + }, }); expect(regular.allowBypass).toBe(false); expect(regular.device?.id).toBe("dev-2"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 0010145a8..5ec7f9965 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -80,7 +80,7 @@ import { type SubsystemLogger = ReturnType; -const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000; +const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000; export function attachGatewayWsMessageHandler(params: { socket: WebSocket; @@ -528,13 +528,12 @@ export function attachGatewayWsMessageHandler(params: { rejectDeviceAuthInvalid("device-signature-stale", "device signature expired"); return; } - const nonceRequired = !isLocalClient; const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; - if (nonceRequired && !providedNonce) { + if (!providedNonce) { rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required"); return; } - if (providedNonce && providedNonce !== connectNonce) { + if (providedNonce !== connectNonce) { rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch"); return; } @@ -546,31 +545,12 @@ export function attachGatewayWsMessageHandler(params: { scopes, signedAtMs: signedAt, token: connectParams.auth?.token ?? null, - nonce: providedNonce || undefined, - version: providedNonce ? "v2" : "v1", + nonce: providedNonce, }); const rejectDeviceSignatureInvalid = () => rejectDeviceAuthInvalid("device-signature", "device signature invalid"); const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature); - const allowLegacy = !nonceRequired && !providedNonce; - if (!signatureOk && allowLegacy) { - const legacyPayload = buildDeviceAuthPayload({ - deviceId: device.id, - clientId: connectParams.client.id, - clientMode: connectParams.client.mode, - role, - scopes, - signedAtMs: signedAt, - token: connectParams.auth?.token ?? null, - version: "v1", - }); - if (verifyDeviceSignature(device.publicKey, legacyPayload, device.signature)) { - // accepted legacy loopback signature - } else { - rejectDeviceSignatureInvalid(); - return; - } - } else if (!signatureOk) { + if (!signatureOk) { rejectDeviceSignatureInvalid(); return; } diff --git a/src/gateway/test-helpers.e2e.ts b/src/gateway/test-helpers.e2e.ts index 5d12461c0..e267921c0 100644 --- a/src/gateway/test-helpers.e2e.ts +++ b/src/gateway/test-helpers.e2e.ts @@ -88,7 +88,43 @@ export async function connectGatewayClient(params: { export async function connectDeviceAuthReq(params: { url: string; token?: string }) { const ws = new WebSocket(params.url); + const connectNoncePromise = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("timeout waiting for connect challenge")), + 5000, + ); + const closeHandler = (code: number, reason: Buffer) => { + clearTimeout(timer); + ws.off("message", handler); + reject(new Error(`closed ${code}: ${rawDataToString(reason)}`)); + }; + const handler = (data: WebSocket.RawData) => { + try { + const obj = JSON.parse(rawDataToString(data)) as { + type?: unknown; + event?: unknown; + payload?: { nonce?: unknown } | null; + }; + if (obj.type !== "event" || obj.event !== "connect.challenge") { + return; + } + const nonce = obj.payload?.nonce; + if (typeof nonce !== "string" || nonce.trim().length === 0) { + return; + } + clearTimeout(timer); + ws.off("message", handler); + ws.off("close", closeHandler); + resolve(nonce.trim()); + } catch { + // ignore parse errors while waiting for challenge + } + }; + ws.on("message", handler); + ws.once("close", closeHandler); + }); await new Promise((resolve) => ws.once("open", resolve)); + const connectNonce = await connectNoncePromise; const identity = loadOrCreateDeviceIdentity(); const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ @@ -99,12 +135,14 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string scopes: [], signedAtMs, token: params.token ?? null, + nonce: connectNonce, }); const device = { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, + nonce: connectNonce, }; ws.send( JSON.stringify({ diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 9c28b5648..c6ba81a16 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -242,6 +242,37 @@ type GatewayTestMessage = { [key: string]: unknown; }; +const CONNECT_CHALLENGE_NONCE_KEY = "__openclawTestConnectChallengeNonce"; +const CONNECT_CHALLENGE_TRACKED_KEY = "__openclawTestConnectChallengeTracked"; +type TrackedWs = WebSocket & Record; + +export function getTrackedConnectChallengeNonce(ws: WebSocket): string | undefined { + const tracked = (ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY]; + return typeof tracked === "string" && tracked.trim().length > 0 ? tracked.trim() : undefined; +} + +export function trackConnectChallengeNonce(ws: WebSocket): void { + const trackedWs = ws as TrackedWs; + if (trackedWs[CONNECT_CHALLENGE_TRACKED_KEY] === true) { + return; + } + trackedWs[CONNECT_CHALLENGE_TRACKED_KEY] = true; + ws.on("message", (data) => { + try { + const obj = JSON.parse(rawDataToString(data)) as GatewayTestMessage; + if (obj.type !== "event" || obj.event !== "connect.challenge") { + return; + } + const nonce = (obj.payload as { nonce?: unknown } | undefined)?.nonce; + if (typeof nonce === "string" && nonce.trim().length > 0) { + trackedWs[CONNECT_CHALLENGE_NONCE_KEY] = nonce.trim(); + } + } catch { + // ignore parse errors in nonce tracker + } + }); +} + export function onceMessage( ws: WebSocket, filter: (obj: T) => boolean, @@ -345,6 +376,7 @@ export async function startServerWithClient( `ws://127.0.0.1:${port}`, wsHeaders ? { headers: wsHeaders } : undefined, ); + trackConnectChallengeNonce(ws); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); const cleanup = () => { @@ -380,6 +412,32 @@ type ConnectResponse = { error?: { message?: string }; }; +export async function readConnectChallengeNonce( + ws: WebSocket, + timeoutMs = 2_000, +): Promise { + const cached = getTrackedConnectChallengeNonce(ws); + if (cached) { + return cached; + } + trackConnectChallengeNonce(ws); + try { + const evt = await onceMessage<{ + type?: string; + event?: string; + payload?: Record | null; + }>(ws, (o) => o.type === "event" && o.event === "connect.challenge", timeoutMs); + const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce; + if (typeof nonce === "string" && nonce.trim().length > 0) { + (ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY] = nonce.trim(); + return nonce.trim(); + } + return undefined; + } catch { + return undefined; + } +} + export async function connectReq( ws: WebSocket, opts?: { @@ -410,6 +468,7 @@ export async function connectReq( signedAt: number; nonce?: string; } | null; + skipConnectChallengeNonce?: boolean; }, ): Promise { const { randomUUID } = await import("node:crypto"); @@ -440,6 +499,11 @@ export async function connectReq( : role === "operator" ? ["operator.admin"] : []; + if (opts?.skipConnectChallengeNonce && opts?.device === undefined) { + throw new Error("skipConnectChallengeNonce requires an explicit device override"); + } + const connectChallengeNonce = + opts?.device !== undefined ? undefined : await readConnectChallengeNonce(ws); const device = (() => { if (opts?.device === null) { return undefined; @@ -447,6 +511,9 @@ export async function connectReq( if (opts?.device) { return opts.device; } + if (!connectChallengeNonce) { + throw new Error("missing connect.challenge nonce"); + } const identity = loadOrCreateDeviceIdentity(); const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ @@ -457,13 +524,14 @@ export async function connectReq( scopes: requestedScopes, signedAtMs, token: token ?? null, + nonce: connectChallengeNonce, }); return { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, - nonce: opts?.device?.nonce, + nonce: connectChallengeNonce, }; })(); ws.send( diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 975cca4ab..27f212c24 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -129,6 +129,11 @@ export class GatewayBrowserClient { if (this.connectSent) { return; } + const nonce = this.connectNonce?.trim() ?? ""; + if (!nonce) { + this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce"); + return; + } this.connectSent = true; if (this.connectTimer !== null) { window.clearTimeout(this.connectTimer); @@ -169,13 +174,12 @@ export class GatewayBrowserClient { publicKey: string; signature: string; signedAt: number; - nonce: string | undefined; + nonce: string; } | undefined; if (isSecureContext && deviceIdentity) { const signedAtMs = Date.now(); - const nonce = this.connectNonce ?? undefined; const payload = buildDeviceAuthPayload({ deviceId: deviceIdentity.deviceId, clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, @@ -249,10 +253,12 @@ export class GatewayBrowserClient { if (evt.event === "connect.challenge") { const payload = evt.payload as { nonce?: unknown } | undefined; const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; - if (nonce) { - this.connectNonce = nonce; - void this.sendConnect(); + if (!nonce || nonce.trim().length === 0) { + this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce"); + return; } + this.connectNonce = nonce.trim(); + void this.sendConnect(); return; } const seq = typeof evt.seq === "number" ? evt.seq : null; @@ -306,7 +312,10 @@ export class GatewayBrowserClient { window.clearTimeout(this.connectTimer); } this.connectTimer = window.setTimeout(() => { - void this.sendConnect(); - }, 750); + if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) { + return; + } + this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge timeout"); + }, 2_000); } }