fix: sync mobile gateway auth v3

This commit is contained in:
Peter Steinberger
2026-01-20 13:30:30 +00:00
parent a4d1c4d522
commit fa51294f65
11 changed files with 173 additions and 37 deletions

View File

@@ -1,15 +1,33 @@
{ {
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748", "originHash" : "2e6f580ad7d1e839d513aa883350369bf2e4193fad872030fdaea7827f34d8ef",
"pins" : [ "pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{ {
"identity" : "elevenlabskit", "identity" : "elevenlabskit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0" "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", "identity" : "swift-syntax",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -27,6 +45,24 @@
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0" "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 "version" : 3

View File

@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceAuthStore
import com.clawdbot.android.gateway.DeviceIdentityStore import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions import com.clawdbot.android.gateway.GatewayConnectOptions
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext) val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController() val canvas = CanvasController()
val camera = CameraCaptureManager(appContext) val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext) val location = LocationCaptureManager(appContext)
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey -> onConnected = { name, remote, mainSessionKey ->
operatorConnected = true operatorConnected = true
operatorStatusText = "Connected" operatorStatusText = "Connected"
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ -> onConnected = { _, _, _ ->
nodeConnected = true nodeConnected = true
nodeStatusText = "Connected" nodeStatusText = "Connected"

View File

@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, fingerprint.trim()) } 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 { private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim() val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing

View File

@@ -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"
}
}

View File

@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
class GatewaySession( class GatewaySession(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore, private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit, private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit,
@@ -177,6 +178,7 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>() private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>() private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false) private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient() private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway" private val loggerTag = "ClawdbotGateway"
@@ -253,7 +255,8 @@ class GatewaySession(
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch { scope.launch {
try { try {
sendConnect() val nonce = awaitConnectNonce()
sendConnect(nonce)
} catch (err: Throwable) { } catch (err: Throwable) {
connectDeferred.completeExceptionally(err) connectDeferred.completeExceptionally(err)
closeQuietly() closeQuietly()
@@ -288,16 +291,30 @@ class GatewaySession(
} }
} }
private suspend fun sendConnect() { private suspend fun sendConnect(connectNonce: String?) {
val payload = buildConnectParams() 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) val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) { if (!res.ok) {
val msg = res.error?.message ?: "connect failed" val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg) throw IllegalStateException(msg)
} }
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() 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() val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults = val sessionDefaults =
@@ -308,7 +325,12 @@ class GatewaySession(
connectDeferred.complete(Unit) connectDeferred.complete(Unit)
} }
private fun buildConnectParams(): JsonObject { private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
val client = options.client val client = options.client
val locale = Locale.getDefault().toLanguageTag() val locale = Locale.getDefault().toLanguageTag()
val clientObj = val clientObj =
@@ -323,22 +345,20 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
} }
val authToken = token?.trim().orEmpty() val password = authPassword?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val authJson = val authJson =
when { when {
authToken.isNotEmpty() -> authToken.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("token", JsonPrimitive(authToken)) put("token", JsonPrimitive(authToken))
} }
authPassword.isNotEmpty() -> password.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("password", JsonPrimitive(authPassword)) put("password", JsonPrimitive(password))
} }
else -> null else -> null
} }
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis() val signedAtMs = System.currentTimeMillis()
val payload = val payload =
buildDeviceAuthPayload( buildDeviceAuthPayload(
@@ -349,6 +369,7 @@ class GatewaySession(
scopes = options.scopes, scopes = options.scopes,
signedAtMs = signedAtMs, signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null, token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
) )
val signature = identityStore.signPayload(payload, identity) val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity) val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -359,6 +380,9 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey)) put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature)) put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs)) put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
} }
} else { } else {
null null
@@ -416,6 +440,13 @@ class GatewaySession(
val event = frame["event"].asStringOrNull() ?: return val event = frame["event"].asStringOrNull() ?: return
val payloadJson = val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() 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) { if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson) handleInvokeEvent(payloadJson)
return return
@@ -423,6 +454,21 @@ class GatewaySession(
onEvent(event, payloadJson) 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) { private fun handleInvokeEvent(payloadJson: String) {
val payload = val payload =
try { try {
@@ -544,19 +590,26 @@ class GatewaySession(
scopes: List<String>, scopes: List<String>,
signedAtMs: Long, signedAtMs: Long,
token: String?, token: String?,
nonce: String?,
): String { ): String {
val scopeString = scopes.joinToString(",") val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty() val authToken = token.orEmpty()
return listOf( val version = if (nonce.isNullOrBlank()) "v1" else "v2"
"v1", val parts =
deviceId, mutableListOf(
clientId, version,
clientMode, deviceId,
role, clientId,
scopeString, clientMode,
signedAtMs.toString(), role,
authToken, scopeString,
).joinToString("|") signedAtMs.toString(),
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
} }
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {

View File

@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
} }
private fun normalizeFingerprint(raw: String): 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' }
} }

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>20260109</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -1,15 +1,13 @@
Sources/Bridge/BridgeClient.swift Sources/Gateway/GatewayConnectionController.swift
Sources/Bridge/BridgeConnectionController.swift Sources/Gateway/GatewayDiscoveryDebugLogView.swift
Sources/Bridge/BridgeDiscoveryDebugLogView.swift Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Bridge/BridgeDiscoveryModel.swift Sources/Gateway/GatewaySettingsStore.swift
Sources/Bridge/BridgeEndpointID.swift Sources/Gateway/KeychainStore.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Camera/CameraController.swift Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift Sources/Chat/ChatSheet.swift
Sources/Chat/IOSBridgeChatTransport.swift Sources/Chat/IOSGatewayChatTransport.swift
Sources/ClawdbotApp.swift Sources/ClawdbotApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift Sources/RootCanvas.swift
Sources/RootTabs.swift Sources/RootTabs.swift
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift Sources/Screen/ScreenWebView.swift
Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift Sources/Settings/VoiceWakeWordsSettingsView.swift

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>20260109</string>
</dict> </dict>
</plist> </plist>

View File

@@ -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 { let data: Data? = switch msg {
case let .data(data): data case let .data(data): data
case let .string(text): text.data(using: .utf8) case let .string(text): text.data(using: .utf8)

View File

@@ -108,5 +108,9 @@ private func sha256Hex(_ data: Data) -> String {
} }
private func normalizeFingerprint(_ raw: String) -> 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)
} }