From 490cb5174d5e32f0b89411d24d3a1945f35fe314 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 14:16:41 +0100 Subject: [PATCH] fix(apps): sign gateway device auth with v3 payload --- .../android/gateway/GatewaySession.kt | 17 ++++- .../OpenClawMacCLI/WizardCommand.swift | 62 +++++++++++++++---- .../Sources/OpenClawKit/GatewayChannel.swift | 62 +++++++++++++++---- 3 files changed, 112 insertions(+), 29 deletions(-) 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 e0aea39768e..a498babaeca 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 @@ -372,7 +372,7 @@ class GatewaySession( val signedAtMs = System.currentTimeMillis() val payload = - buildDeviceAuthPayload( + buildDeviceAuthPayloadV3( deviceId = identity.deviceId, clientId = client.id, clientMode = client.mode, @@ -381,6 +381,8 @@ class GatewaySession( signedAtMs = signedAtMs, token = if (authToken.isNotEmpty()) authToken else null, nonce = connectNonce, + platform = client.platform, + deviceFamily = client.deviceFamily, ) val signature = identityStore.signPayload(payload, identity) val publicKey = identityStore.publicKeyBase64Url(identity) @@ -582,7 +584,7 @@ class GatewaySession( } } - private fun buildDeviceAuthPayload( + private fun buildDeviceAuthPayloadV3( deviceId: String, clientId: String, clientMode: String, @@ -591,12 +593,16 @@ class GatewaySession( signedAtMs: Long, token: String?, nonce: String, + platform: String?, + deviceFamily: String?, ): String { val scopeString = scopes.joinToString(",") val authToken = token.orEmpty() + val platformNorm = normalizeDeviceMetadataField(platform) + val deviceFamilyNorm = normalizeDeviceMetadataField(deviceFamily) val parts = mutableListOf( - "v2", + "v3", deviceId, clientId, clientMode, @@ -605,10 +611,15 @@ class GatewaySession( signedAtMs.toString(), authToken, nonce, + platformNorm, + deviceFamilyNorm, ) return parts.joinToString("|") } + private fun normalizeDeviceMetadataField(value: String?): String = + value?.trim()?.lowercase(Locale.ROOT).orEmpty() + private fun normalizeCanvasHostUrl( raw: String?, endpoint: GatewayEndpoint, diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index ebe3e8ae626..0ed00f3565b 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -280,19 +280,17 @@ actor GatewayWizardClient { let connectNonce = try await self.waitForConnectChallenge() let identity = DeviceIdentityStore.loadOrCreate() let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) - let scopesValue = scopes.joined(separator: ",") - let payloadParts = [ - "v2", - identity.deviceId, - clientId, - clientMode, - role, - scopesValue, - String(signedAtMs), - self.token ?? "", - connectNonce, - ] - let payload = payloadParts.joined(separator: "|") + let payload = buildDeviceAuthPayloadV3( + deviceId: identity.deviceId, + clientId: clientId, + clientMode: clientMode, + role: role, + scopes: scopes, + signedAtMs: signedAtMs, + token: self.token, + nonce: connectNonce, + platform: platform, + deviceFamily: "Mac") if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { @@ -329,6 +327,44 @@ actor GatewayWizardClient { } } + private func buildDeviceAuthPayloadV3( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: [String], + signedAtMs: Int, + token: String?, + nonce: String, + platform: String?, + deviceFamily: String?) -> String + { + let scopeString = scopes.joined(separator: ",") + let authToken = token ?? "" + let normalizedPlatform = normalizeMetadataField(platform) + let normalizedDeviceFamily = normalizeMetadataField(deviceFamily) + return [ + "v3", + deviceId, + clientId, + clientMode, + role, + scopeString, + String(signedAtMs), + authToken, + nonce, + normalizedPlatform, + normalizedDeviceFamily, + ].joined(separator: "|") + } + + private func normalizeMetadataField(_ value: String?) -> String { + guard let value else { return "" } + return value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(with: Locale(identifier: "en_US_POSIX")) + } + private func waitForConnectChallenge() async throws -> String { guard let task = self.task else { throw ConnectChallengeError.timeout } return try await AsyncTimeout.withTimeout( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 30935df79d4..ab377ef91dc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -398,20 +398,18 @@ public actor GatewayChannelActor { } let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let connectNonce = try await self.waitForConnectChallenge() - let scopesValue = scopes.joined(separator: ",") - let payloadParts = [ - "v2", - identity?.deviceId ?? "", - clientId, - clientMode, - role, - scopesValue, - String(signedAtMs), - authToken ?? "", - connectNonce, - ] - let payload = payloadParts.joined(separator: "|") if includeDeviceIdentity, let identity { + let payload = buildDeviceAuthPayloadV3( + deviceId: identity.deviceId, + clientId: clientId, + clientMode: clientMode, + role: role, + scopes: scopes, + signedAtMs: signedAtMs, + token: authToken, + nonce: connectNonce, + platform: platform, + deviceFamily: InstanceIdentity.deviceFamily) if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { let device: [String: ProtoAnyCodable] = [ @@ -445,6 +443,44 @@ public actor GatewayChannelActor { } } + private func buildDeviceAuthPayloadV3( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: [String], + signedAtMs: Int, + token: String?, + nonce: String, + platform: String?, + deviceFamily: String?) -> String + { + let scopeString = scopes.joined(separator: ",") + let authToken = token ?? "" + let normalizedPlatform = normalizeMetadataField(platform) + let normalizedDeviceFamily = normalizeMetadataField(deviceFamily) + return [ + "v3", + deviceId, + clientId, + clientMode, + role, + scopeString, + String(signedAtMs), + authToken, + nonce, + normalizedPlatform, + normalizedDeviceFamily, + ].joined(separator: "|") + } + + private func normalizeMetadataField(_ value: String?) -> String { + guard let value else { return "" } + return value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(with: Locale(identifier: "en_US_POSIX")) + } + private func handleConnectResponse( _ res: ResponseFrame, identity: DeviceIdentity?,