From fa51294f65104348cc25567e3d7c4564be62585f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 13:30:30 +0000 Subject: [PATCH] fix: sync mobile gateway auth v3 --- Swabble/Package.resolved | 40 +++++++- .../java/com/clawdbot/android/NodeRuntime.kt | 4 + .../java/com/clawdbot/android/SecurePrefs.kt | 12 +++ .../android/gateway/DeviceAuthStore.kt | 26 ++++++ .../android/gateway/GatewaySession.kt | 91 +++++++++++++++---- .../clawdbot/android/gateway/GatewayTls.kt | 4 +- apps/ios/Sources/Info.plist | 4 +- apps/ios/SwiftSources.input.xcfilelist | 17 ++-- apps/ios/Tests/Info.plist | 4 +- .../Sources/ClawdbotKit/GatewayChannel.swift | 2 +- .../ClawdbotKit/GatewayTLSPinning.swift | 6 +- 11 files changed, 173 insertions(+), 37 deletions(-) create mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt diff --git a/Swabble/Package.resolved b/Swabble/Package.resolved index b6c2147ce..9c3edd665 100644 --- a/Swabble/Package.resolved +++ b/Swabble/Package.resolved @@ -1,15 +1,33 @@ { - "originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748", + "originHash" : "2e6f580ad7d1e839d513aa883350369bf2e4193fad872030fdaea7827f34d8ef", "pins" : [ + { + "identity" : "commander", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Commander.git", + "state" : { + "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", + "version" : "0.2.1" + } + }, { "identity" : "elevenlabskit", "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/ElevenLabsKit", "state" : { - "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", + "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", "version" : "0.1.0" } }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -27,6 +45,24 @@ "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", "version" : "0.99.0" } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual", + "state" : { + "revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3", + "version" : "0.2.0" + } } ], "version" : 3 diff --git a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt b/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt index d132b7ff4..8d051a421 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt @@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage import com.clawdbot.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.OutgoingAttachment +import com.clawdbot.android.gateway.DeviceAuthStore import com.clawdbot.android.gateway.DeviceIdentityStore import com.clawdbot.android.gateway.GatewayClientInfo import com.clawdbot.android.gateway.GatewayConnectOptions @@ -62,6 +63,7 @@ class NodeRuntime(context: Context) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val prefs = SecurePrefs(appContext) + private val deviceAuthStore = DeviceAuthStore(prefs) val canvas = CanvasController() val camera = CameraCaptureManager(appContext) val location = LocationCaptureManager(appContext) @@ -153,6 +155,7 @@ class NodeRuntime(context: Context) { GatewaySession( scope = scope, identityStore = identityStore, + deviceAuthStore = deviceAuthStore, onConnected = { name, remote, mainSessionKey -> operatorConnected = true operatorStatusText = "Connected" @@ -188,6 +191,7 @@ class NodeRuntime(context: Context) { GatewaySession( scope = scope, identityStore = identityStore, + deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> nodeConnected = true nodeStatusText = "Connected" diff --git a/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt b/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt index b109c0661..cd6270dd5 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt @@ -189,6 +189,18 @@ class SecurePrefs(context: Context) { prefs.edit { putString(key, fingerprint.trim()) } } + fun getString(key: String): String? { + return prefs.getString(key, null) + } + + fun putString(key: String, value: String) { + prefs.edit { putString(key, value) } + } + + fun remove(key: String) { + prefs.edit { remove(key) } + } + private fun loadOrCreateInstanceId(): String { val existing = prefs.getString("node.instanceId", null)?.trim() if (!existing.isNullOrBlank()) return existing diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt new file mode 100644 index 000000000..88643d8d7 --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt @@ -0,0 +1,26 @@ +package com.clawdbot.android.gateway + +import com.clawdbot.android.SecurePrefs + +class DeviceAuthStore(private val prefs: SecurePrefs) { + fun loadToken(deviceId: String, role: String): String? { + val key = tokenKey(deviceId, role) + return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } + } + + fun saveToken(deviceId: String, role: String, token: String) { + val key = tokenKey(deviceId, role) + prefs.putString(key, token.trim()) + } + + fun clearToken(deviceId: String, role: String) { + val key = tokenKey(deviceId, role) + prefs.remove(key) + } + + private fun tokenKey(deviceId: String, role: String): String { + val normalizedDevice = deviceId.trim().lowercase() + val normalizedRole = role.trim().lowercase() + return "gateway.deviceToken.$normalizedDevice.$normalizedRole" + } +} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt index cf4d4a81d..ddd249a8e 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt @@ -55,6 +55,7 @@ data class GatewayConnectOptions( class GatewaySession( private val scope: CoroutineScope, private val identityStore: DeviceIdentityStore, + private val deviceAuthStore: DeviceAuthStore, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onDisconnected: (message: String) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit, @@ -177,6 +178,7 @@ class GatewaySession( private val connectDeferred = CompletableDeferred() private val closedDeferred = CompletableDeferred() private val isClosed = AtomicBoolean(false) + private val connectNonceDeferred = CompletableDeferred() private val client: OkHttpClient = buildClient() private var socket: WebSocket? = null private val loggerTag = "ClawdbotGateway" @@ -253,7 +255,8 @@ class GatewaySession( override fun onOpen(webSocket: WebSocket, response: Response) { scope.launch { try { - sendConnect() + val nonce = awaitConnectNonce() + sendConnect(nonce) } catch (err: Throwable) { connectDeferred.completeExceptionally(err) closeQuietly() @@ -288,16 +291,30 @@ class GatewaySession( } } - private suspend fun sendConnect() { - val payload = buildConnectParams() + private suspend fun sendConnect(connectNonce: String?) { + val identity = identityStore.loadOrCreate() + val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) + val trimmedToken = token?.trim().orEmpty() + val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken + val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank() + val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) val res = request("connect", payload, timeoutMs = 8_000) if (!res.ok) { val msg = res.error?.message ?: "connect failed" + if (canFallbackToShared) { + deviceAuthStore.clearToken(identity.deviceId, options.role) + } throw IllegalStateException(msg) } val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() + val authObj = obj["auth"].asObjectOrNull() + val deviceToken = authObj?.get("deviceToken").asStringOrNull() + val authRole = authObj?.get("role").asStringOrNull() ?: options.role + if (!deviceToken.isNullOrBlank()) { + deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken) + } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) val sessionDefaults = @@ -308,7 +325,12 @@ class GatewaySession( connectDeferred.complete(Unit) } - private fun buildConnectParams(): JsonObject { + private fun buildConnectParams( + identity: DeviceIdentity, + connectNonce: String?, + authToken: String, + authPassword: String?, + ): JsonObject { val client = options.client val locale = Locale.getDefault().toLanguageTag() val clientObj = @@ -323,22 +345,20 @@ class GatewaySession( client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } } - val authToken = token?.trim().orEmpty() - val authPassword = password?.trim().orEmpty() + val password = authPassword?.trim().orEmpty() val authJson = when { authToken.isNotEmpty() -> buildJsonObject { put("token", JsonPrimitive(authToken)) } - authPassword.isNotEmpty() -> + password.isNotEmpty() -> buildJsonObject { - put("password", JsonPrimitive(authPassword)) + put("password", JsonPrimitive(password)) } else -> null } - val identity = identityStore.loadOrCreate() val signedAtMs = System.currentTimeMillis() val payload = buildDeviceAuthPayload( @@ -349,6 +369,7 @@ class GatewaySession( scopes = options.scopes, signedAtMs = signedAtMs, token = if (authToken.isNotEmpty()) authToken else null, + nonce = connectNonce, ) val signature = identityStore.signPayload(payload, identity) val publicKey = identityStore.publicKeyBase64Url(identity) @@ -359,6 +380,9 @@ class GatewaySession( put("publicKey", JsonPrimitive(publicKey)) put("signature", JsonPrimitive(signature)) put("signedAt", JsonPrimitive(signedAtMs)) + if (!connectNonce.isNullOrBlank()) { + put("nonce", JsonPrimitive(connectNonce)) + } } } else { null @@ -416,6 +440,13 @@ class GatewaySession( val event = frame["event"].asStringOrNull() ?: return val payloadJson = frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() + if (event == "connect.challenge") { + val nonce = extractConnectNonce(payloadJson) + if (!connectNonceDeferred.isCompleted) { + connectNonceDeferred.complete(nonce) + } + return + } if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { handleInvokeEvent(payloadJson) return @@ -423,6 +454,21 @@ class GatewaySession( onEvent(event, payloadJson) } + private suspend fun awaitConnectNonce(): String? { + if (isLoopbackHost(endpoint.host)) return null + return try { + withTimeout(2_000) { connectNonceDeferred.await() } + } catch (_: Throwable) { + null + } + } + + private fun extractConnectNonce(payloadJson: String?): String? { + if (payloadJson.isNullOrBlank()) return null + val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null + return obj["nonce"].asStringOrNull() + } + private fun handleInvokeEvent(payloadJson: String) { val payload = try { @@ -544,19 +590,26 @@ class GatewaySession( scopes: List, signedAtMs: Long, token: String?, + nonce: String?, ): String { val scopeString = scopes.joinToString(",") val authToken = token.orEmpty() - return listOf( - "v1", - deviceId, - clientId, - clientMode, - role, - scopeString, - signedAtMs.toString(), - authToken, - ).joinToString("|") + val version = if (nonce.isNullOrBlank()) "v1" else "v2" + val parts = + mutableListOf( + version, + deviceId, + clientId, + clientMode, + role, + scopeString, + signedAtMs.toString(), + authToken, + ) + if (!nonce.isNullOrBlank()) { + parts.add(nonce) + } + return parts.joinToString("|") } private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt index cd77f2a7f..bcca51583 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt @@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String { } private fun normalizeFingerprint(raw: String): String { - return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' } + val stripped = raw.trim() + .replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "") + return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' } } diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index e1ea41706..72e0901d7 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.11-4 + 2026.1.9 CFBundleVersion - 202601113 + 20260109 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 0598b0e4e..70d0f39d6 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -1,15 +1,13 @@ -Sources/Bridge/BridgeClient.swift -Sources/Bridge/BridgeConnectionController.swift -Sources/Bridge/BridgeDiscoveryDebugLogView.swift -Sources/Bridge/BridgeDiscoveryModel.swift -Sources/Bridge/BridgeEndpointID.swift -Sources/Bridge/BridgeSession.swift -Sources/Bridge/BridgeSettingsStore.swift -Sources/Bridge/KeychainStore.swift +Sources/Gateway/GatewayConnectionController.swift +Sources/Gateway/GatewayDiscoveryDebugLogView.swift +Sources/Gateway/GatewayDiscoveryModel.swift +Sources/Gateway/GatewaySettingsStore.swift +Sources/Gateway/KeychainStore.swift Sources/Camera/CameraController.swift Sources/Chat/ChatSheet.swift -Sources/Chat/IOSBridgeChatTransport.swift +Sources/Chat/IOSGatewayChatTransport.swift Sources/ClawdbotApp.swift +Sources/Location/LocationService.swift Sources/Model/NodeAppModel.swift Sources/RootCanvas.swift Sources/RootTabs.swift @@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenTab.swift Sources/Screen/ScreenWebView.swift +Sources/SessionKey.swift Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsTab.swift Sources/Settings/VoiceWakeWordsSettingsView.swift diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 0213b646b..d577244b7 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.1.11-4 + 2026.1.9 CFBundleVersion - 202601113 + 20260109 diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift index e89f07e51..a40a864b7 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift @@ -498,7 +498,7 @@ public actor GatewayChannelActor { } } - private func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? { + private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? { let data: Data? = switch msg { case let .data(data): data case let .string(text): text.data(using: .utf8) diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift index 26552b3bd..ade3f463b 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift @@ -108,5 +108,9 @@ private func sha256Hex(_ data: Data) -> String { } private func normalizeFingerprint(_ raw: String) -> String { - raw.lowercased().filter(\.isHexDigit) + let stripped = raw.replacingOccurrences( + of: #"(?i)^sha-?256\s*:?\s*"#, + with: "", + options: .regularExpression) + return stripped.lowercased().filter(\.isHexDigit) }