From 1ab1e312b25b03ce5b9eb0318a65c81be7d9908b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 05:28:33 +0000 Subject: [PATCH] feat: add TLS for node bridge --- .../java/com/clawdbot/android/NodeRuntime.kt | 45 ++++ .../java/com/clawdbot/android/SecurePrefs.kt | 10 + .../android/bridge/BridgeDiscovery.kt | 18 ++ .../clawdbot/android/bridge/BridgeEndpoint.kt | 4 + .../android/bridge/BridgePairingClient.kt | 194 ++++++++++-------- .../clawdbot/android/bridge/BridgeSession.kt | 119 ++++++----- .../com/clawdbot/android/bridge/BridgeTls.kt | 79 +++++++ apps/ios/Sources/Bridge/BridgeClient.swift | 40 +++- .../Bridge/BridgeConnectionController.swift | 52 ++++- .../Sources/Bridge/BridgeDiscoveryModel.swift | 9 + apps/ios/Sources/Bridge/BridgeSession.swift | 43 +++- apps/ios/Sources/Bridge/BridgeTLS.swift | 67 ++++++ apps/ios/Sources/Model/NodeAppModel.swift | 2 + apps/ios/Sources/Settings/SettingsTab.swift | 50 ++++- .../BridgeConnectionControllerTests.swift | 7 + .../Sources/Clawdbot/MacNodeConfigFile.swift | 1 + .../NodeMode/MacNodeBridgePairingClient.swift | 43 +++- .../NodeMode/MacNodeBridgeSession.swift | 56 ++++- .../Clawdbot/NodeMode/MacNodeBridgeTLS.swift | 75 +++++++ .../NodeMode/MacNodeModeCoordinator.swift | 121 +++++++++-- .../Clawdbot/NodeMode/MacNodeRuntime.swift | 38 +++- docs/gateway/bridge-protocol.md | 4 + docs/gateway/configuration.md | 19 +- docs/platforms/macos.md | 2 +- src/config/types.gateway.ts | 14 ++ src/config/zod-schema.ts | 9 + src/gateway/server-bridge-runtime.ts | 15 +- src/gateway/server-discovery-runtime.ts | 5 + src/gateway/server-node-bridge.ts | 6 +- src/infra/bonjour-discovery.ts | 7 + src/infra/bonjour.ts | 8 + src/infra/bridge/server/start.ts | 6 +- src/infra/bridge/server/tls.ts | 152 ++++++++++++++ src/infra/bridge/server/types.ts | 3 + src/infra/node-pairing.ts | 10 + src/infra/widearea-dns.ts | 8 + 36 files changed, 1161 insertions(+), 180 deletions(-) create mode 100644 apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeTls.kt create mode 100644 apps/ios/Sources/Bridge/BridgeTLS.swift create mode 100644 apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift create mode 100644 src/infra/bridge/server/tls.ts 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 469c7c3ba..83054c4e1 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 @@ -16,6 +16,7 @@ import com.clawdbot.android.bridge.BridgeDiscovery import com.clawdbot.android.bridge.BridgeEndpoint import com.clawdbot.android.bridge.BridgePairingClient import com.clawdbot.android.bridge.BridgeSession +import com.clawdbot.android.bridge.BridgeTlsParams import com.clawdbot.android.node.CameraCaptureManager import com.clawdbot.android.node.LocationCaptureManager import com.clawdbot.android.BuildConfig @@ -160,6 +161,9 @@ class NodeRuntime(context: Context) { onInvoke = { req -> handleInvoke(req.command, req.paramsJson) }, + onTlsFingerprint = { stableId, fingerprint -> + prefs.saveBridgeTlsFingerprint(stableId, fingerprint) + }, ) private val chat = ChatController(scope = scope, session = session, json = json) @@ -488,12 +492,17 @@ class NodeRuntime(context: Context) { scope.launch { _statusText.value = "Connecting…" val storedToken = prefs.loadBridgeToken() + val tls = resolveTlsParams(endpoint) val resolved = if (storedToken.isNullOrBlank()) { _statusText.value = "Pairing…" BridgePairingClient().pairAndHello( endpoint = endpoint, hello = buildPairingHello(token = null), + tls = tls, + onTlsFingerprint = { fingerprint -> + prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint) + }, ) } else { BridgePairingClient.PairResult(ok = true, token = storedToken.trim()) @@ -510,6 +519,7 @@ class NodeRuntime(context: Context) { session.connect( endpoint = endpoint, hello = buildSessionHello(token = authToken), + tls = tls, ) } } @@ -556,6 +566,41 @@ class NodeRuntime(context: Context) { session.disconnect() } + private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? { + val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId) + val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() + val manual = endpoint.stableId.startsWith("manual|") + + if (hinted) { + return BridgeTlsParams( + required = true, + expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored, + allowTOFU = stored == null, + stableId = endpoint.stableId, + ) + } + + if (!stored.isNullOrBlank()) { + return BridgeTlsParams( + required = true, + expectedFingerprint = stored, + allowTOFU = false, + stableId = endpoint.stableId, + ) + } + + if (manual) { + return BridgeTlsParams( + required = false, + expectedFingerprint = null, + allowTOFU = true, + stableId = endpoint.stableId, + ) + } + + return null + } + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { scope.launch { val trimmed = payloadJson.trim() 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 1b1c955fd..3b02c88a5 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 @@ -147,6 +147,16 @@ class SecurePrefs(context: Context) { prefs.edit { putString(key, token.trim()) } } + fun loadBridgeTlsFingerprint(stableId: String): String? { + val key = "bridge.tls.$stableId" + return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } + } + + fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) { + val key = "bridge.tls.$stableId" + prefs.edit { putString(key, fingerprint.trim()) } + } + 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/bridge/BridgeDiscovery.kt b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeDiscovery.kt index 287f069cd..d619200bf 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeDiscovery.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeDiscovery.kt @@ -143,6 +143,8 @@ class BridgeDiscovery( val gatewayPort = txtInt(resolved, "gatewayPort") val bridgePort = txtInt(resolved, "bridgePort") val canvasPort = txtInt(resolved, "canvasPort") + val tlsEnabled = txtBool(resolved, "bridgeTls") + val tlsFingerprint = txt(resolved, "bridgeTlsSha256") val id = stableId(serviceName, "local.") localById[id] = BridgeEndpoint( @@ -155,6 +157,8 @@ class BridgeDiscovery( gatewayPort = gatewayPort, bridgePort = bridgePort, canvasPort = canvasPort, + tlsEnabled = tlsEnabled, + tlsFingerprintSha256 = tlsFingerprint, ) publish() } @@ -209,6 +213,11 @@ class BridgeDiscovery( return txt(info, key)?.toIntOrNull() } + private fun txtBool(info: NsdServiceInfo, key: String): Boolean { + val raw = txt(info, key)?.trim()?.lowercase() ?: return false + return raw == "1" || raw == "true" || raw == "yes" + } + private suspend fun refreshUnicast(domain: String) { val ptrName = "${serviceType}${domain}" val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return @@ -252,6 +261,8 @@ class BridgeDiscovery( val gatewayPort = txtIntValue(txt, "gatewayPort") val bridgePort = txtIntValue(txt, "bridgePort") val canvasPort = txtIntValue(txt, "canvasPort") + val tlsEnabled = txtBoolValue(txt, "bridgeTls") + val tlsFingerprint = txtValue(txt, "bridgeTlsSha256") val id = stableId(instanceName, domain) next[id] = BridgeEndpoint( @@ -264,6 +275,8 @@ class BridgeDiscovery( gatewayPort = gatewayPort, bridgePort = bridgePort, canvasPort = canvasPort, + tlsEnabled = tlsEnabled, + tlsFingerprintSha256 = tlsFingerprint, ) } @@ -474,6 +487,11 @@ class BridgeDiscovery( return txtValue(records, key)?.toIntOrNull() } + private fun txtBoolValue(records: List, key: String): Boolean { + val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false + return raw == "1" || raw == "true" || raw == "yes" + } + private fun decodeDnsTxtString(raw: String): String { // dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible. diff --git a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeEndpoint.kt b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeEndpoint.kt index 0f90730ba..c86352d76 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeEndpoint.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeEndpoint.kt @@ -10,6 +10,8 @@ data class BridgeEndpoint( val gatewayPort: Int? = null, val bridgePort: Int? = null, val canvasPort: Int? = null, + val tlsEnabled: Boolean = false, + val tlsFingerprintSha256: String? = null, ) { companion object { fun manual(host: String, port: Int): BridgeEndpoint = @@ -18,6 +20,8 @@ data class BridgeEndpoint( name = "$host:$port", host = host, port = port, + tlsEnabled = false, + tlsFingerprintSha256 = null, ) } } diff --git a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgePairingClient.kt b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgePairingClient.kt index e57faea9e..00ecbd25e 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgePairingClient.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgePairingClient.kt @@ -14,7 +14,6 @@ import java.io.BufferedWriter import java.io.InputStreamReader import java.io.OutputStreamWriter import java.net.InetSocketAddress -import java.net.Socket class BridgePairingClient { private val json = Json { ignoreUnknownKeys = true } @@ -33,95 +32,120 @@ class BridgePairingClient { data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) - suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult = + suspend fun pairAndHello( + endpoint: BridgeEndpoint, + hello: Hello, + tls: BridgeTlsParams? = null, + onTlsFingerprint: ((String) -> Unit)? = null, + ): PairResult = withContext(Dispatchers.IO) { - val socket = Socket() - socket.tcpNoDelay = true - try { - socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) - socket.soTimeout = 60_000 - - val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) - val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) - - fun send(line: String) { - writer.write(line) - writer.write("\n") - writer.flush() - } - - fun sendJson(obj: JsonObject) = send(obj.toString()) - - sendJson( - buildJsonObject { - put("type", JsonPrimitive("hello")) - put("nodeId", JsonPrimitive(hello.nodeId)) - hello.displayName?.let { put("displayName", JsonPrimitive(it)) } - hello.token?.let { put("token", JsonPrimitive(it)) } - hello.platform?.let { put("platform", JsonPrimitive(it)) } - hello.version?.let { put("version", JsonPrimitive(it)) } - hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } - hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } - hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } - hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) } - }, - ) - - val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull() - ?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response") - when (firstObj["type"].asStringOrNull()) { - "hello-ok" -> PairResult(ok = true, token = hello.token) - "error" -> { - val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE" - val message = firstObj["message"].asStringOrNull() ?: "pairing required" - if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") { - return@withContext PairResult(ok = false, token = null, error = "$code: $message") - } - - sendJson( - buildJsonObject { - put("type", JsonPrimitive("pair-request")) - put("nodeId", JsonPrimitive(hello.nodeId)) - hello.displayName?.let { put("displayName", JsonPrimitive(it)) } - hello.platform?.let { put("platform", JsonPrimitive(it)) } - hello.version?.let { put("version", JsonPrimitive(it)) } - hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } - hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } - hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } - hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) } - }, - ) - - while (true) { - val nextLine = reader.readLine() ?: break - val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue - when (next["type"].asStringOrNull()) { - "pair-ok" -> { - val token = next["token"].asStringOrNull() - return@withContext PairResult(ok = !token.isNullOrBlank(), token = token) - } - "error" -> { - val c = next["code"].asStringOrNull() ?: "UNAVAILABLE" - val m = next["message"].asStringOrNull() ?: "pairing failed" - return@withContext PairResult(ok = false, token = null, error = "$c: $m") - } - } - } - PairResult(ok = false, token = null, error = "pairing failed") - } - else -> PairResult(ok = false, token = null, error = "unexpected bridge response") - } - } catch (e: Exception) { - val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" } - PairResult(ok = false, token = null, error = message) - } finally { + if (tls != null) { try { - socket.close() - } catch (_: Throwable) { - // ignore + return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint) + } catch (e: Exception) { + if (tls.required) throw e } } + pairAndHelloWithTls(endpoint, hello, null, null) } + + private fun pairAndHelloWithTls( + endpoint: BridgeEndpoint, + hello: Hello, + tls: BridgeTlsParams?, + onTlsFingerprint: ((String) -> Unit)?, + ): PairResult { + val socket = + createBridgeSocket(tls) { fingerprint -> + onTlsFingerprint?.invoke(fingerprint) + } + socket.tcpNoDelay = true + try { + socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) + socket.soTimeout = 60_000 + startTlsHandshakeIfNeeded(socket) + + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) + + fun send(line: String) { + writer.write(line) + writer.write("\n") + writer.flush() + } + + fun sendJson(obj: JsonObject) = send(obj.toString()) + + sendJson( + buildJsonObject { + put("type", JsonPrimitive("hello")) + put("nodeId", JsonPrimitive(hello.nodeId)) + hello.displayName?.let { put("displayName", JsonPrimitive(it)) } + hello.token?.let { put("token", JsonPrimitive(it)) } + hello.platform?.let { put("platform", JsonPrimitive(it)) } + hello.version?.let { put("version", JsonPrimitive(it)) } + hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } + hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } + hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } + hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) } + }, + ) + + val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull() + ?: return PairResult(ok = false, token = null, error = "unexpected bridge response") + return when (firstObj["type"].asStringOrNull()) { + "hello-ok" -> PairResult(ok = true, token = hello.token) + "error" -> { + val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE" + val message = firstObj["message"].asStringOrNull() ?: "pairing required" + if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") { + return PairResult(ok = false, token = null, error = "$code: $message") + } + + sendJson( + buildJsonObject { + put("type", JsonPrimitive("pair-request")) + put("nodeId", JsonPrimitive(hello.nodeId)) + hello.displayName?.let { put("displayName", JsonPrimitive(it)) } + hello.platform?.let { put("platform", JsonPrimitive(it)) } + hello.version?.let { put("version", JsonPrimitive(it)) } + hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } + hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } + hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } + hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) } + }, + ) + + while (true) { + val nextLine = reader.readLine() ?: break + val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue + when (next["type"].asStringOrNull()) { + "pair-ok" -> { + val token = next["token"].asStringOrNull() + return PairResult(ok = !token.isNullOrBlank(), token = token) + } + "error" -> { + val c = next["code"].asStringOrNull() ?: "UNAVAILABLE" + val m = next["message"].asStringOrNull() ?: "pairing failed" + return PairResult(ok = false, token = null, error = "$c: $m") + } + } + } + PairResult(ok = false, token = null, error = "pairing failed") + } + else -> PairResult(ok = false, token = null, error = "unexpected bridge response") + } + } catch (e: Exception) { + val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" } + return PairResult(ok = false, token = null, error = message) + } finally { + try { + socket.close() + } catch (_: Throwable) { + // ignore + } + } + } } private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject diff --git a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt index 1135068cc..0a12c1eb3 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt @@ -35,6 +35,7 @@ class BridgeSession( private val onDisconnected: (message: String) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onInvoke: suspend (InvokeRequest) -> InvokeResult, + private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, ) { data class Hello( val nodeId: String, @@ -66,11 +67,17 @@ class BridgeSession( @Volatile private var canvasHostUrl: String? = null @Volatile private var mainSessionKey: String? = null - private var desired: Pair? = null + private data class DesiredConnection( + val endpoint: BridgeEndpoint, + val hello: Hello, + val tls: BridgeTlsParams?, + ) + + private var desired: DesiredConnection? = null private var job: Job? = null - fun connect(endpoint: BridgeEndpoint, hello: Hello) { - desired = endpoint to hello + fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) { + desired = DesiredConnection(endpoint, hello, tls) if (job == null) { job = scope.launch(Dispatchers.IO) { runLoop() } } @@ -78,7 +85,7 @@ class BridgeSession( suspend fun updateHello(hello: Hello) { val target = desired ?: return - desired = target.first to hello + desired = target.copy(hello = hello) val conn = currentConnection ?: return conn.sendJson(buildHelloJson(hello)) } @@ -165,10 +172,10 @@ class BridgeSession( continue } - val (endpoint, hello) = target + val (endpoint, hello, tls) = target try { onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") - connectOnce(endpoint, hello) + connectOnce(endpoint, hello, tls) attempt = 0 } catch (err: Throwable) { attempt += 1 @@ -192,50 +199,66 @@ class BridgeSession( return InvokeResult.error(code = "UNAVAILABLE", message = msg) } - private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) = + private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) = withContext(Dispatchers.IO) { - val socket = Socket() - socket.tcpNoDelay = true - socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) - socket.soTimeout = 0 - - val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) - val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) - - val conn = Connection(socket, reader, writer, writeLock) - currentConnection = conn - - try { - conn.sendJson(buildHelloJson(hello)) - - val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection") - val first = json.parseToJsonElement(firstLine).asObjectOrNull() - ?: throw IllegalStateException("unexpected bridge response") - when (first["type"].asStringOrNull()) { - "hello-ok" -> { - val name = first["serverName"].asStringOrNull() ?: "Bridge" - val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } - val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null } - canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint) - mainSessionKey = rawMainSessionKey - if (BuildConfig.DEBUG) { - // Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked". - runCatching { - android.util.Log.d( - "ClawdbotBridge", - "canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})", - ) - } - } - onConnected(name, conn.remoteAddress, rawMainSessionKey) - } - "error" -> { - val code = first["code"].asStringOrNull() ?: "UNAVAILABLE" - val msg = first["message"].asStringOrNull() ?: "connect failed" - throw IllegalStateException("$code: $msg") - } - else -> throw IllegalStateException("unexpected bridge response") + if (tls != null) { + try { + connectWithSocket(endpoint, hello, tls) + return@withContext + } catch (err: Throwable) { + if (tls.required) throw err } + } + connectWithSocket(endpoint, hello, null) + } + + private fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) { + val socket = + createBridgeSocket(tls) { fingerprint -> + onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint) + } + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) + socket.soTimeout = 0 + startTlsHandshakeIfNeeded(socket) + + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) + + val conn = Connection(socket, reader, writer, writeLock) + currentConnection = conn + + try { + conn.sendJson(buildHelloJson(hello)) + + val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection") + val first = json.parseToJsonElement(firstLine).asObjectOrNull() + ?: throw IllegalStateException("unexpected bridge response") + when (first["type"].asStringOrNull()) { + "hello-ok" -> { + val name = first["serverName"].asStringOrNull() ?: "Bridge" + val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } + val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null } + canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint) + mainSessionKey = rawMainSessionKey + if (BuildConfig.DEBUG) { + // Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked". + runCatching { + android.util.Log.d( + "ClawdbotBridge", + "canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})", + ) + } + } + onConnected(name, conn.remoteAddress, rawMainSessionKey) + } + "error" -> { + val code = first["code"].asStringOrNull() ?: "UNAVAILABLE" + val msg = first["message"].asStringOrNull() ?: "connect failed" + throw IllegalStateException("$code: $msg") + } + else -> throw IllegalStateException("unexpected bridge response") + } while (scope.isActive) { val line = reader.readLine() ?: break diff --git a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeTls.kt b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeTls.kt new file mode 100644 index 000000000..a0156abb5 --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeTls.kt @@ -0,0 +1,79 @@ +package com.clawdbot.android.bridge + +import java.net.Socket +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +data class BridgeTlsParams( + val required: Boolean, + val expectedFingerprint: String?, + val allowTOFU: Boolean, + val stableId: String, +) + +fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket { + if (params == null) return Socket() + val expected = params.expectedFingerprint?.let(::normalizeFingerprint) + val defaultTrust = defaultTrustManager() + val trustManager = + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) { + defaultTrust.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array, authType: String) { + if (chain.isEmpty()) throw CertificateException("empty certificate chain") + val fingerprint = sha256Hex(chain[0].encoded) + if (expected != null) { + if (fingerprint != expected) { + throw CertificateException("bridge TLS fingerprint mismatch") + } + return + } + if (params.allowTOFU) { + onStore?.invoke(fingerprint) + return + } + defaultTrust.checkServerTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array = defaultTrust.acceptedIssuers + } + + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(trustManager), SecureRandom()) + return context.socketFactory.createSocket() +} + +fun startTlsHandshakeIfNeeded(socket: Socket) { + if (socket is SSLSocket) { + socket.startHandshake() + } +} + +private fun defaultTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as java.security.KeyStore?) + val trust = + factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager + return trust ?: throw IllegalStateException("No default X509TrustManager found") +} + +private fun sha256Hex(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(data) + val out = StringBuilder(digest.size * 2) + for (byte in digest) { + out.append(String.format("%02x", byte)) + } + return out.toString() +} + +private fun normalizeFingerprint(raw: String): String { + return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' } +} diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index d4204fb4f..f880e6896 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -10,10 +10,36 @@ actor BridgeClient { func pairAndHello( endpoint: NWEndpoint, hello: BridgeHello, + tls: BridgeTLSParams? = nil, + onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String + { + do { + return try await self.pairAndHelloOnce( + endpoint: endpoint, + hello: hello, + tls: tls, + onStatus: onStatus) + } catch { + if let tls, !tls.required { + return try await self.pairAndHelloOnce( + endpoint: endpoint, + hello: hello, + tls: nil, + onStatus: onStatus) + } + throw error + } + } + + private func pairAndHelloOnce( + endpoint: NWEndpoint, + hello: BridgeHello, + tls: BridgeTLSParams?, onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String { self.lineBuffer = Data() - let connection = NWConnection(to: endpoint, using: .tcp) + let params = self.makeParameters(tls: tls) + let connection = NWConnection(to: endpoint, using: params) let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client") defer { connection.cancel() } try await self.withTimeout(seconds: 8, purpose: "connect") { @@ -142,6 +168,18 @@ actor BridgeClient { } } + private func makeParameters(tls: BridgeTLSParams?) -> NWParameters { + if let tlsOptions = makeBridgeTLSOptions(tls) { + let tcpOptions = NWProtocolTCP.Options() + let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) + params.includePeerToPeer = true + return params + } + let params = NWParameters.tcp + params.includePeerToPeer = true + return params + } + private struct TimeoutError: LocalizedError, Sendable { var purpose: String var seconds: Int diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index d06e3253b..01a6c6c0a 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -10,6 +10,7 @@ protocol BridgePairingClient: Sendable { func pairAndHello( endpoint: NWEndpoint, hello: BridgeHello, + tls: BridgeTLSParams?, onStatus: (@Sendable (String) -> Void)?) async throws -> String } @@ -115,9 +116,12 @@ final class BridgeConnectionController { self.didAutoConnect = true let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port) + let stableID = BridgeEndpointID.stableID(endpoint) + let tlsParams = self.resolveManualTLSParams(stableID: stableID) self.startAutoConnect( endpoint: endpoint, - bridgeStableID: BridgeEndpointID.stableID(endpoint), + bridgeStableID: stableID, + tls: tlsParams, token: token, instanceId: instanceId) return @@ -135,10 +139,12 @@ final class BridgeConnectionController { guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } + let tlsParams = self.resolveDiscoveredTLSParams(bridge: target) self.didAutoConnect = true self.startAutoConnect( endpoint: target.endpoint, bridgeStableID: target.stableID, + tls: tlsParams, token: token, instanceId: instanceId) } @@ -182,6 +188,7 @@ final class BridgeConnectionController { private func startAutoConnect( endpoint: NWEndpoint, bridgeStableID: String, + tls: BridgeTLSParams?, token: String, instanceId: String) { @@ -193,6 +200,7 @@ final class BridgeConnectionController { let refreshed = try await self.bridgeClientFactory().pairAndHello( endpoint: endpoint, hello: hello, + tls: tls, onStatus: { status in Task { @MainActor in appModel.bridgeStatusText = status @@ -208,6 +216,7 @@ final class BridgeConnectionController { appModel.connectToBridge( endpoint: endpoint, bridgeStableID: bridgeStableID, + tls: tls, hello: self.makeHello(token: resolvedToken)) } catch { await MainActor.run { @@ -217,6 +226,47 @@ final class BridgeConnectionController { } } + private func resolveDiscoveredTLSParams( + bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams? + { + let stableID = bridge.stableID + let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) + + if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil { + return BridgeTLSParams( + required: true, + expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored, + allowTOFU: stored == nil, + storeKey: stableID) + } + + if let stored { + return BridgeTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + return nil + } + + private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? { + if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) { + return BridgeTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + return BridgeTLSParams( + required: false, + expectedFingerprint: nil, + allowTOFU: true, + storeKey: stableID) + } + private func resolvedDisplayName(defaults: UserDefaults) -> String { let key = "node.displayName" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index fbe8485f4..ad7d010bd 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -23,6 +23,8 @@ final class BridgeDiscoveryModel { var gatewayPort: Int? var bridgePort: Int? var canvasPort: Int? + var tlsEnabled: Bool + var tlsFingerprintSha256: String? var cliPath: String? } @@ -90,6 +92,8 @@ final class BridgeDiscoveryModel { gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), bridgePort: Self.txtIntValue(txt, key: "bridgePort"), canvasPort: Self.txtIntValue(txt, key: "canvasPort"), + tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"), + tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"), cliPath: Self.txtValue(txt, key: "cliPath")) default: return nil @@ -214,4 +218,9 @@ final class BridgeDiscoveryModel { guard let raw = self.txtValue(dict, key: key) else { return nil } return Int(raw) } + + private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool { + guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false } + return raw == "1" || raw == "true" || raw == "yes" + } } diff --git a/apps/ios/Sources/Bridge/BridgeSession.swift b/apps/ios/Sources/Bridge/BridgeSession.swift index 41ccf413d..adc3e0c00 100644 --- a/apps/ios/Sources/Bridge/BridgeSession.swift +++ b/apps/ios/Sources/Bridge/BridgeSession.swift @@ -69,15 +69,42 @@ actor BridgeSession { func connect( endpoint: NWEndpoint, hello: BridgeHello, + tls: BridgeTLSParams? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws { await self.disconnect() self.state = .connecting + do { + try await self.connectOnce( + endpoint: endpoint, + hello: hello, + tls: tls, + onConnected: onConnected, + onInvoke: onInvoke) + } catch { + if let tls, !tls.required { + try await self.connectOnce( + endpoint: endpoint, + hello: hello, + tls: nil, + onConnected: onConnected, + onInvoke: onInvoke) + return + } + throw error + } + } - let params = NWParameters.tcp - params.includePeerToPeer = true + private func connectOnce( + endpoint: NWEndpoint, + hello: BridgeHello, + tls: BridgeTLSParams?, + onConnected: (@Sendable (String, String?) async -> Void)?, + onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws + { + let params = self.makeParameters(tls: tls) let connection = NWConnection(to: endpoint, using: params) let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session") self.connection = connection @@ -255,6 +282,18 @@ actor BridgeSession { } } + private func makeParameters(tls: BridgeTLSParams?) -> NWParameters { + if let tlsOptions = makeBridgeTLSOptions(tls) { + let tcpOptions = NWProtocolTCP.Options() + let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) + params.includePeerToPeer = true + return params + } + let params = NWParameters.tcp + params.includePeerToPeer = true + return params + } + private func timeoutRPC(id: String) async { guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [ diff --git a/apps/ios/Sources/Bridge/BridgeTLS.swift b/apps/ios/Sources/Bridge/BridgeTLS.swift new file mode 100644 index 000000000..a7f2223e9 --- /dev/null +++ b/apps/ios/Sources/Bridge/BridgeTLS.swift @@ -0,0 +1,67 @@ +import CryptoKit +import Foundation +import Network +import Security + +struct BridgeTLSParams: Sendable { + let required: Bool + let expectedFingerprint: String? + let allowTOFU: Bool + let storeKey: String? +} + +enum BridgeTLSStore { + private static let service = "com.clawdbot.bridge.tls" + + static func loadFingerprint(stableID: String) -> String? { + KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func saveFingerprint(_ value: String, stableID: String) { + _ = KeychainStore.saveString(value, service: service, account: stableID) + } +} + +func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? { + guard let params else { return nil } + let options = NWProtocolTLS.Options() + let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint) + let allowTOFU = params.allowTOFU + let storeKey = params.storeKey + + sec_protocol_options_set_verify_block( + options.securityProtocolOptions, + { _, trust, complete in + guard let trust else { + complete(false) + return + } + if let cert = SecTrustGetCertificateAtIndex(trust, 0) { + let data = SecCertificateCopyData(cert) as Data + let fingerprint = sha256Hex(data) + if let expected { + complete(fingerprint == expected) + return + } + if allowTOFU { + if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) } + complete(true) + return + } + } + let ok = SecTrustEvaluateWithError(trust, nil) + complete(ok) + }, + DispatchQueue(label: "com.clawdbot.bridge.tls.verify")) + + return options +} + +private func sha256Hex(_ data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() +} + +private func normalizeBridgeFingerprint(_ raw: String) -> String { + raw.lowercased().filter { $0.isHexDigit } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 683c090ae..258d79f4a 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -205,6 +205,7 @@ final class NodeAppModel { func connectToBridge( endpoint: NWEndpoint, bridgeStableID: String, + tls: BridgeTLSParams?, hello: BridgeHello) { self.bridgeTask?.cancel() @@ -232,6 +233,7 @@ final class NodeAppModel { try await self.bridge.connect( endpoint: endpoint, hello: hello, + tls: tls, onConnected: { [weak self] serverName, mainSessionKey in guard let self else { return } await MainActor.run { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 2eb1c2644..bc474ff00 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -407,9 +407,11 @@ struct SettingsTab: View { modelIdentifier: self.modelIdentifier(), caps: self.currentCaps(), commands: self.currentCommands()) + let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge) let token = try await BridgeClient().pairAndHello( endpoint: bridge.endpoint, hello: hello, + tls: tlsParams, onStatus: { status in Task { @MainActor in statusStore.text = status @@ -426,6 +428,7 @@ struct SettingsTab: View { self.appModel.connectToBridge( endpoint: bridge.endpoint, bridgeStableID: bridge.stableID, + tls: tlsParams, hello: BridgeHello( nodeId: self.instanceId, displayName: self.displayName, @@ -462,6 +465,8 @@ struct SettingsTab: View { defer { self.connectingBridgeID = nil } let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) + let stableID = BridgeEndpointID.stableID(endpoint) + let tlsParams = self.resolveManualTLSParams(stableID: stableID) do { let statusStore = self.connectStatus @@ -485,6 +490,7 @@ struct SettingsTab: View { let token = try await BridgeClient().pairAndHello( endpoint: endpoint, hello: hello, + tls: tlsParams, onStatus: { status in Task { @MainActor in statusStore.text = status @@ -500,7 +506,8 @@ struct SettingsTab: View { self.appModel.connectToBridge( endpoint: endpoint, - bridgeStableID: BridgeEndpointID.stableID(endpoint), + bridgeStableID: stableID, + tls: tlsParams, hello: BridgeHello( nodeId: self.instanceId, displayName: self.displayName, @@ -517,6 +524,47 @@ struct SettingsTab: View { } } + private func resolveDiscoveredTLSParams( + bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams? + { + let stableID = bridge.stableID + let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) + + if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil { + return BridgeTLSParams( + required: true, + expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored, + allowTOFU: stored == nil, + storeKey: stableID) + } + + if let stored { + return BridgeTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + return nil + } + + private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? { + if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) { + return BridgeTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + return BridgeTLSParams( + required: false, + expectedFingerprint: nil, + allowTOFU: true, + storeKey: stableID) + } + private static func primaryIPv4Address() -> String? { var addrList: UnsafeMutablePointer? guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } diff --git a/apps/ios/Tests/BridgeConnectionControllerTests.swift b/apps/ios/Tests/BridgeConnectionControllerTests.swift index b78844b70..df6198623 100644 --- a/apps/ios/Tests/BridgeConnectionControllerTests.swift +++ b/apps/ios/Tests/BridgeConnectionControllerTests.swift @@ -27,6 +27,7 @@ private actor MockBridgePairingClient: BridgePairingClient { func pairAndHello( endpoint: NWEndpoint, hello: BridgeHello, + tls: BridgeTLSParams?, onStatus: (@Sendable (String) -> Void)?) async throws -> String { self.lastToken = hello.token @@ -244,6 +245,8 @@ private func withKeychainValues( gatewayPort: 18789, bridgePort: 18790, canvasPort: 18793, + tlsEnabled: false, + tlsFingerprintSha256: nil, cliPath: nil) let mock = MockBridgePairingClient(resultToken: "new-token") let account = "bridge-token.ios-test" @@ -292,6 +295,8 @@ private func withKeychainValues( gatewayPort: 18789, bridgePort: 18790, canvasPort: 18793, + tlsEnabled: false, + tlsFingerprintSha256: nil, cliPath: nil) let bridgeB = BridgeDiscoveryModel.DiscoveredBridge( name: "Gateway B", @@ -303,6 +308,8 @@ private func withKeychainValues( gatewayPort: 28789, bridgePort: 28790, canvasPort: 28793, + tlsEnabled: false, + tlsFingerprintSha256: nil, cliPath: nil) let mock = MockBridgePairingClient(resultToken: "token-ok") diff --git a/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift b/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift index e70deb98f..7b5f9934f 100644 --- a/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift +++ b/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift @@ -32,6 +32,7 @@ enum MacNodeConfigFile { at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } catch { self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)") } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift index 2feae8482..38b4877bc 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift @@ -11,10 +11,39 @@ actor MacNodeBridgePairingClient { endpoint: NWEndpoint, hello: BridgeHello, silent: Bool, + tls: MacNodeBridgeTLSParams? = nil, + onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String + { + do { + return try await self.pairAndHelloOnce( + endpoint: endpoint, + hello: hello, + silent: silent, + tls: tls, + onStatus: onStatus) + } catch { + if let tls, !tls.required { + return try await self.pairAndHelloOnce( + endpoint: endpoint, + hello: hello, + silent: silent, + tls: nil, + onStatus: onStatus) + } + throw error + } + } + + private func pairAndHelloOnce( + endpoint: NWEndpoint, + hello: BridgeHello, + silent: Bool, + tls: MacNodeBridgeTLSParams?, onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String { self.lineBuffer = Data() - let connection = NWConnection(to: endpoint, using: .tcp) + let params = self.makeParameters(tls: tls) + let connection = NWConnection(to: endpoint, using: params) let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client") defer { connection.cancel() } try await AsyncTimeout.withTimeout( @@ -164,6 +193,18 @@ actor MacNodeBridgePairingClient { } } + private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters { + let tcpOptions = NWProtocolTCP.Options() + if let tlsOptions = makeMacNodeTLSOptions(tls) { + let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) + params.includePeerToPeer = true + return params + } + let params = NWParameters.tcp + params.includePeerToPeer = true + return params + } + private func startAndWaitForReady( _ connection: NWConnection, queue: DispatchQueue) async throws diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index 03a13c685..f0aad68fa 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -36,6 +36,7 @@ actor MacNodeBridgeSession { func connect( endpoint: NWEndpoint, hello: BridgeHello, + tls: MacNodeBridgeTLSParams? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) @@ -44,15 +45,35 @@ actor MacNodeBridgeSession { await self.disconnect() self.disconnectHandler = onDisconnected self.state = .connecting + do { + try await self.connectOnce( + endpoint: endpoint, + hello: hello, + tls: tls, + onConnected: onConnected, + onInvoke: onInvoke) + } catch { + if let tls, !tls.required { + try await self.connectOnce( + endpoint: endpoint, + hello: hello, + tls: nil, + onConnected: onConnected, + onInvoke: onInvoke) + return + } + throw error + } + } - let params = NWParameters.tcp - params.includePeerToPeer = true - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.enableKeepalive = true - tcpOptions.keepaliveIdle = 30 - tcpOptions.keepaliveInterval = 15 - tcpOptions.keepaliveCount = 3 - params.defaultProtocolStack.transportProtocol = tcpOptions + private func connectOnce( + endpoint: NWEndpoint, + hello: BridgeHello, + tls: MacNodeBridgeTLSParams?, + onConnected: (@Sendable (String, String?) async -> Void)? = nil, + onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws + { + let params = self.makeParameters(tls: tls) let connection = NWConnection(to: endpoint, using: params) let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session") self.connection = connection @@ -262,6 +283,25 @@ actor MacNodeBridgeSession { } } + private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters { + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 30 + tcpOptions.keepaliveInterval = 15 + tcpOptions.keepaliveCount = 3 + + if let tlsOptions = makeMacNodeTLSOptions(tls) { + let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) + params.includePeerToPeer = true + return params + } + + let params = NWParameters.tcp + params.includePeerToPeer = true + params.defaultProtocolStack.transportProtocol = tcpOptions + return params + } + private func failRPC(id: String, error: Error) async { if let cont = self.pendingRPC.removeValue(forKey: id) { cont.resume(throwing: error) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift new file mode 100644 index 000000000..192214d6c --- /dev/null +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift @@ -0,0 +1,75 @@ +import CryptoKit +import Foundation +import Network +import Security + +struct MacNodeBridgeTLSParams: Sendable { + let required: Bool + let expectedFingerprint: String? + let allowTOFU: Bool + let storeKey: String? +} + +enum MacNodeBridgeTLSStore { + private static let suiteName = "com.clawdbot.shared" + private static let keyPrefix = "mac.node.bridge.tls." + + private static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } + + static func loadFingerprint(stableID: String) -> String? { + let key = keyPrefix + stableID + let raw = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) + return raw?.isEmpty == false ? raw : nil + } + + static func saveFingerprint(_ value: String, stableID: String) { + let key = keyPrefix + stableID + defaults.set(value, forKey: key) + } +} + +func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? { + guard let params else { return nil } + let options = NWProtocolTLS.Options() + let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint) + let allowTOFU = params.allowTOFU + let storeKey = params.storeKey + + sec_protocol_options_set_verify_block( + options.securityProtocolOptions, + { _, trust, complete in + guard let trust else { + complete(false) + return + } + if let cert = SecTrustGetCertificateAtIndex(trust, 0) { + let data = SecCertificateCopyData(cert) as Data + let fingerprint = sha256Hex(data) + if let expected { + complete(fingerprint == expected) + return + } + if allowTOFU { + if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) } + complete(true) + return + } + } + let ok = SecTrustEvaluateWithError(trust, nil) + complete(ok) + }, + DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify")) + + return options +} + +private func sha256Hex(_ data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() +} + +private func normalizeMacNodeFingerprint(_ raw: String) -> String { + raw.lowercased().filter { $0.isHexDigit } +} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index c7f86d6c9..bc7fa0c7f 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -4,6 +4,12 @@ import Foundation import Network import OSLog +private struct BridgeTarget { + let endpoint: NWEndpoint + let stableID: String + let tls: MacNodeBridgeTLSParams? +} + @MainActor final class MacNodeModeCoordinator { static let shared = MacNodeModeCoordinator() @@ -63,7 +69,7 @@ final class MacNodeModeCoordinator { try? await Task.sleep(nanoseconds: 200_000_000) } - guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else { + guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else { try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000)) retryDelay = min(retryDelay * 2, 10_000_000_000) continue @@ -73,10 +79,11 @@ final class MacNodeModeCoordinator { do { let hello = await self.makeHello() self.logger.info( - "mac node bridge connecting endpoint=\(endpoint, privacy: .public)") + "mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)") try await self.session.connect( - endpoint: endpoint, + endpoint: target.endpoint, hello: hello, + tls: target.tls, onConnected: { [weak self] serverName, mainSessionKey in self?.logger.info("mac node connected to \(serverName, privacy: .public)") if let mainSessionKey { @@ -96,7 +103,7 @@ final class MacNodeModeCoordinator { return await self.runtime.handleInvoke(req) }) } catch { - if await self.tryPair(endpoint: endpoint, error: error) { + if await self.tryPair(target: target, error: error) { continue } self.logger.error( @@ -173,7 +180,7 @@ final class MacNodeModeCoordinator { return commands } - private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool { + private func tryPair(target: BridgeTarget, error: Error) async -> Bool { let text = error.localizedDescription.uppercased() guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false } @@ -183,9 +190,10 @@ final class MacNodeModeCoordinator { } let hello = await self.makeHello() let token = try await MacNodeBridgePairingClient().pairAndHello( - endpoint: endpoint, + endpoint: target.endpoint, hello: hello, silent: shouldSilent, + tls: target.tls, onStatus: { [weak self] status in self?.logger.info("mac node pairing: \(status, privacy: .public)") }) @@ -203,7 +211,7 @@ final class MacNodeModeCoordinator { "mac-\(InstanceIdentity.instanceId)" } - private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? { + private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? { guard let port = Self.loopbackBridgePort(), let endpointPort = NWEndpoint.Port(rawValue: port) else { @@ -211,7 +219,10 @@ final class MacNodeModeCoordinator { } let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort) let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds) - return reachable ? endpoint : nil + guard reachable else { return nil } + let stableID = BridgeEndpointID.stableID(endpoint) + let tlsParams = Self.resolveManualTLSParams(stableID: stableID) + return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams) } static func loopbackBridgePort() -> UInt16? { @@ -304,7 +315,7 @@ final class MacNodeModeCoordinator { }) } - private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? { + private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? { let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode }) if mode == .remote { do { @@ -316,7 +327,10 @@ final class MacNodeModeCoordinator { if healthy, let port = NWEndpoint.Port(rawValue: localPort) { self.logger.info( "reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)") - return .hostPort(host: "127.0.0.1", port: port) + let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port) + let stableID = BridgeEndpointID.stableID(endpoint) + let tlsParams = Self.resolveManualTLSParams(stableID: stableID) + return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams) } self.logger.error( "mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting") @@ -349,7 +363,10 @@ final class MacNodeModeCoordinator { "mac node bridge tunnel ready " + "localPort=\(localPort, privacy: .public) " + "remotePort=\(remotePort, privacy: .public)") - return .hostPort(host: "127.0.0.1", port: port) + let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port) + let stableID = BridgeEndpointID.stableID(endpoint) + let tlsParams = Self.resolveManualTLSParams(stableID: stableID) + return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams) } } catch { self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)") @@ -360,8 +377,8 @@ final class MacNodeModeCoordinator { tunnel.terminate() self.tunnel = nil } - if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) { - return endpoint + if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) { + return target } return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds) } @@ -381,14 +398,14 @@ final class MacNodeModeCoordinator { return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds) } - private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? { + private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? { final class DiscoveryState: @unchecked Sendable { let lock = NSLock() var resolved = false var browsers: [NWBrowser] = [] - var continuation: CheckedContinuation? + var continuation: CheckedContinuation? - func finish(_ endpoint: NWEndpoint?) { + func finish(_ target: BridgeTarget?) { self.lock.lock() defer { lock.unlock() } if self.resolved { return } @@ -396,7 +413,7 @@ final class MacNodeModeCoordinator { for browser in self.browsers { browser.cancel() } - self.continuation?.resume(returning: endpoint) + self.continuation?.resume(returning: target) self.continuation = nil } } @@ -422,12 +439,12 @@ final class MacNodeModeCoordinator { return false }) { - state.finish(match.endpoint) + state.finish(Self.targetFromResult(match)) return } if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) { - state.finish(result.endpoint) + state.finish(Self.targetFromResult(result)) } } browser.stateUpdateHandler = { browserState in @@ -445,6 +462,72 @@ final class MacNodeModeCoordinator { } } } + + private static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? { + let endpoint = result.endpoint + guard case .service = endpoint else { return nil } + let stableID = BridgeEndpointID.stableID(endpoint) + let txt = result.endpoint.txtRecord?.dictionary ?? [:] + let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls") + let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256") + let tlsParams = Self.resolveDiscoveredTLSParams( + stableID: stableID, + tlsEnabled: tlsEnabled, + tlsFingerprintSha256: tlsFingerprint) + return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams) + } + + private static func resolveDiscoveredTLSParams( + stableID: String, + tlsEnabled: Bool, + tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams? + { + let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) + + if tlsEnabled || tlsFingerprintSha256 != nil { + return MacNodeBridgeTLSParams( + required: true, + expectedFingerprint: tlsFingerprintSha256 ?? stored, + allowTOFU: stored == nil, + storeKey: stableID) + } + + if let stored { + return MacNodeBridgeTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + return nil + } + + private static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? { + if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) { + return MacNodeBridgeTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + return MacNodeBridgeTLSParams( + required: false, + expectedFingerprint: nil, + allowTOFU: true, + storeKey: stableID) + } + + private static func txtValue(_ dict: [String: String], key: String) -> String? { + let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? nil : raw + } + + private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool { + guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false } + return raw == "1" || raw == "true" || raw == "yes" + } } enum MacNodeTokenStore { diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 088aba104..723b0ae61 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -455,14 +455,7 @@ actor MacNodeRuntime { } } - var env = params.env - if wasAllowlisted, let overrides = env { - var merged = ProcessInfo.processInfo.environment - for (key, value) in overrides where key != "PATH" { - merged[key] = value - } - env = merged - } + let env = Self.sanitizedEnv(params.env) if params.needsScreenRecording == true { let authorized = await PermissionManager @@ -571,6 +564,35 @@ actor MacNodeRuntime { SystemRunPolicy.load() } + private static let blockedEnvKeys: Set = [ + "PATH", + "NODE_OPTIONS", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYOPT", + ] + + private static let blockedEnvPrefixes: [String] = [ + "DYLD_", + "LD_", + ] + + private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? { + guard let overrides else { return nil } + var merged = ProcessInfo.processInfo.environment + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + if blockedEnvKeys.contains(upper) { continue } + if blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue } + merged[key] = value + } + return merged + } + private nonisolated static func locationMode() -> ClawdbotLocationMode { let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" return ClawdbotLocationMode(rawValue: raw) ?? .off diff --git a/docs/gateway/bridge-protocol.md b/docs/gateway/bridge-protocol.md index 995e2fe18..c5ef56016 100644 --- a/docs/gateway/bridge-protocol.md +++ b/docs/gateway/bridge-protocol.md @@ -28,8 +28,12 @@ If you are building an operator client (CLI, web UI, automations), use the ## Transport - TCP, one JSON object per line (JSONL). +- Optional TLS (when `bridge.tls.enabled` is true). - Gateway owns the listener (default `18790`). +When TLS is enabled, discovery TXT records include `bridgeTls=1` plus +`bridgeTlsSha256` so nodes can pin the certificate. + ## Handshake + pairing 1) Client sends `hello` with node metadata + token (if already paired). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 4fcb02090..1510163cf 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2732,12 +2732,29 @@ Bind modes: - `loopback`: `127.0.0.1` (local only) - `auto`: prefer tailnet IP if present, else `lan` +TLS: +- `bridge.tls.enabled`: enable TLS for bridge connections (TLS-only when enabled). +- `bridge.tls.autoGenerate`: generate a self-signed cert when no cert/key are present (default: true). +- `bridge.tls.certPath` / `bridge.tls.keyPath`: PEM paths for the bridge certificate + private key. +- `bridge.tls.caPath`: optional PEM CA bundle (custom roots or future mTLS). + +When TLS is enabled, the Gateway advertises `bridgeTls=1` and `bridgeTlsSha256` in discovery TXT +records so nodes can pin the certificate. Manual connections use trust-on-first-use if no +fingerprint is stored yet. +Auto-generated certs require `openssl` on PATH; if generation fails, the bridge will not start. + ```json5 { bridge: { enabled: true, port: 18790, - bind: "tailnet" + bind: "tailnet", + tls: { + enabled: true, + // Uses ~/.clawdbot/bridge/tls/bridge-{cert,key}.pem when omitted. + // certPath: "~/.clawdbot/bridge/tls/bridge-cert.pem", + // keyPath: "~/.clawdbot/bridge/tls/bridge-key.pem" + } } } ``` diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 81225dc16..c54ccdba2 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -84,7 +84,7 @@ Schema: Notes: - `allowlist` entries are JSON-encoded argv arrays. - Choosing “Always Allow” in the prompt adds that command to the allowlist. -- Allowlisted runs ignore `PATH` overrides; other env vars are merged with the app’s environment. +- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment. ## Deep links diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index e18917711..69a0392ed 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -11,6 +11,20 @@ export type BridgeConfig = { * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway) */ bind?: BridgeBindMode; + tls?: BridgeTlsConfig; +}; + +export type BridgeTlsConfig = { + /** Enable TLS for the node bridge server. */ + enabled?: boolean; + /** Auto-generate a self-signed cert if cert/key are missing (default: true). */ + autoGenerate?: boolean; + /** PEM certificate path for the bridge server. */ + certPath?: string; + /** PEM private key path for the bridge server. */ + keyPath?: string; + /** Optional PEM CA bundle for TLS clients (mTLS or custom roots). */ + caPath?: string; }; export type WideAreaDiscoveryConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a50c420ba..f75558fd7 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -171,6 +171,15 @@ export const ClawdbotSchema = z bind: z .union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")]) .optional(), + tls: z + .object({ + enabled: z.boolean().optional(), + autoGenerate: z.boolean().optional(), + certPath: z.string().optional(), + keyPath: z.string().optional(), + caPath: z.string().optional(), + }) + .optional(), }) .optional(), discovery: z diff --git a/src/gateway/server-bridge-runtime.ts b/src/gateway/server-bridge-runtime.ts index 1aa15dbe2..ac3bb796a 100644 --- a/src/gateway/server-bridge-runtime.ts +++ b/src/gateway/server-bridge-runtime.ts @@ -6,6 +6,7 @@ import type { HealthSummary } from "../commands/health.js"; import type { ClawdbotConfig } from "../config/config.js"; import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js"; import type { NodeBridgeServer } from "../infra/bridge/server.js"; +import { loadBridgeTlsRuntime } from "../infra/bridge/server/tls.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; import type { RuntimeEnv } from "../runtime.js"; import type { ChatAbortControllerEntry } from "./chat-abort.js"; @@ -71,7 +72,7 @@ export async function startGatewayBridgeRuntime(params: { }): Promise { const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true; - const bridgeEnabled = (() => { + let bridgeEnabled = (() => { if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true; return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0"; })(); @@ -111,6 +112,14 @@ export async function startGatewayBridgeRuntime(params: { return "0.0.0.0"; })(); + const bridgeTls = bridgeEnabled + ? await loadBridgeTlsRuntime(params.cfg.bridge?.tls, params.logBridge) + : { enabled: false, required: false }; + if (bridgeTls.required && !bridgeTls.enabled) { + params.logBridge.warn(bridgeTls.error ?? "bridge tls: failed to enable; bridge disabled"); + bridgeEnabled = false; + } + const canvasHostPort = (() => { if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) { const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10); @@ -197,6 +206,7 @@ export async function startGatewayBridgeRuntime(params: { bridgeEnabled, bridgePort, bridgeHost, + bridgeTls: bridgeTls.enabled ? bridgeTls : undefined, machineDisplayName: params.machineDisplayName, canvasHostPort: canvasHostPortForBridge, canvasHostHost: canvasHostHostForBridge, @@ -212,6 +222,9 @@ export async function startGatewayBridgeRuntime(params: { machineDisplayName: params.machineDisplayName, port: params.port, bridgePort: bridge?.port, + bridgeTls: bridgeTls.enabled + ? { enabled: true, fingerprintSha256: bridgeTls.fingerprintSha256 } + : undefined, canvasPort: canvasHostPortForBridge, wideAreaDiscoveryEnabled, logDiscovery: params.logDiscovery, diff --git a/src/gateway/server-discovery-runtime.ts b/src/gateway/server-discovery-runtime.ts index 8610d9267..e2179391a 100644 --- a/src/gateway/server-discovery-runtime.ts +++ b/src/gateway/server-discovery-runtime.ts @@ -11,6 +11,7 @@ export async function startGatewayDiscovery(params: { machineDisplayName: string; port: number; bridgePort?: number; + bridgeTls?: { enabled: boolean; fingerprintSha256?: string }; canvasPort?: number; wideAreaDiscoveryEnabled: boolean; logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void }; @@ -27,6 +28,8 @@ export async function startGatewayDiscovery(params: { gatewayPort: params.port, bridgePort: params.bridgePort, canvasPort: params.canvasPort, + bridgeTlsEnabled: params.bridgeTls?.enabled ?? false, + bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256, sshPort, tailnetDns, cliPath: resolveBonjourCliPath(), @@ -51,6 +54,8 @@ export async function startGatewayDiscovery(params: { displayName: formatBonjourInstanceName(params.machineDisplayName), tailnetIPv4, tailnetIPv6: tailnetIPv6 ?? undefined, + bridgeTlsEnabled: params.bridgeTls?.enabled ?? false, + bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256, tailnetDns, sshPort, cliPath: resolveBonjourCliPath(), diff --git a/src/gateway/server-node-bridge.ts b/src/gateway/server-node-bridge.ts index d07c847ad..0a6a9ca0a 100644 --- a/src/gateway/server-node-bridge.ts +++ b/src/gateway/server-node-bridge.ts @@ -1,5 +1,6 @@ import type { NodeBridgeServer } from "../infra/bridge/server.js"; import { startNodeBridgeServer } from "../infra/bridge/server.js"; +import type { BridgeTlsRuntime } from "../infra/bridge/server/tls.js"; import type { ClawdbotConfig } from "../config/config.js"; import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js"; @@ -23,6 +24,7 @@ export async function startGatewayNodeBridge(params: { bridgeEnabled: boolean; bridgePort: number; bridgeHost: string | null; + bridgeTls?: BridgeTlsRuntime; machineDisplayName: string; canvasHostPort?: number; canvasHostHost?: string; @@ -111,6 +113,7 @@ export async function startGatewayNodeBridge(params: { const started = await startNodeBridgeServer({ host: params.bridgeHost, port: params.bridgePort, + tls: params.bridgeTls?.tlsOptions, serverName: params.machineDisplayName, canvasHostPort: params.canvasHostPort, canvasHostHost: params.canvasHostHost, @@ -158,7 +161,8 @@ export async function startGatewayNodeBridge(params: { }, }); if (started.port > 0) { - params.logBridge.info(`listening on tcp://${params.bridgeHost}:${started.port} (node)`); + const scheme = params.bridgeTls?.enabled ? "tls" : "tcp"; + params.logBridge.info(`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`); return { bridge: started, nodePresenceTimers }; } } catch (err) { diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index bb7cf4604..60c175914 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -12,6 +12,8 @@ export type GatewayBonjourBeacon = { bridgePort?: number; gatewayPort?: number; sshPort?: number; + bridgeTls?: boolean; + bridgeTlsFingerprintSha256?: string; cliPath?: string; txt?: Record; }; @@ -206,6 +208,11 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour beacon.bridgePort = parseIntOrNull(txt.bridgePort); beacon.gatewayPort = parseIntOrNull(txt.gatewayPort); beacon.sshPort = parseIntOrNull(txt.sshPort); + if (txt.bridgeTls) { + const raw = txt.bridgeTls.trim().toLowerCase(); + beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes"; + } + if (txt.bridgeTlsSha256) beacon.bridgeTlsFingerprintSha256 = txt.bridgeTlsSha256; if (!beacon.displayName) beacon.displayName = decodedInstanceName; return beacon; diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 2a57f1543..1c65b7d0f 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -16,6 +16,8 @@ export type GatewayBonjourAdvertiseOpts = { sshPort?: number; bridgePort?: number; canvasPort?: number; + bridgeTlsEnabled?: boolean; + bridgeTlsFingerprintSha256?: string; tailnetDns?: string; cliPath?: string; }; @@ -107,6 +109,12 @@ export async function startGatewayBonjourAdvertiser( if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) { txtBase.canvasPort = String(opts.canvasPort); } + if (opts.bridgeTlsEnabled) { + txtBase.bridgeTls = "1"; + if (opts.bridgeTlsFingerprintSha256) { + txtBase.bridgeTlsSha256 = opts.bridgeTlsFingerprintSha256; + } + } if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) { txtBase.tailnetDns = opts.tailnetDns.trim(); } diff --git a/src/infra/bridge/server/start.ts b/src/infra/bridge/server/start.ts index ef360a54d..b6a058762 100644 --- a/src/infra/bridge/server/start.ts +++ b/src/infra/bridge/server/start.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import net from "node:net"; import os from "node:os"; +import tls from "node:tls"; import { resolveCanvasHostUrl } from "../../canvas-host-url.js"; @@ -47,7 +48,8 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise const loopbackHost = "127.0.0.1"; const listeners: Array<{ host: string; server: net.Server }> = []; - const primary = net.createServer(onConnection); + const createServer = () => (opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection)); + const primary = createServer(); await new Promise((resolve, reject) => { const onError = (err: Error) => reject(err); primary.once("error", onError); @@ -65,7 +67,7 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise const port = typeof address === "object" && address ? address.port : opts.port; if (shouldAlsoListenOnLoopback(opts.host)) { - const loopback = net.createServer(onConnection); + const loopback = createServer(); try { await new Promise((resolve, reject) => { const onError = (err: Error) => reject(err); diff --git a/src/infra/bridge/server/tls.ts b/src/infra/bridge/server/tls.ts new file mode 100644 index 000000000..0461c1bd0 --- /dev/null +++ b/src/infra/bridge/server/tls.ts @@ -0,0 +1,152 @@ +import { execFile } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import tls from "node:tls"; +import { promisify } from "node:util"; + +import type { BridgeTlsConfig } from "../../../config/types.gateway.js"; +import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../../utils.js"; + +const execFileAsync = promisify(execFile); + +export type BridgeTlsRuntime = { + enabled: boolean; + required: boolean; + certPath?: string; + keyPath?: string; + caPath?: string; + fingerprintSha256?: string; + tlsOptions?: tls.TlsOptions; + error?: string; +}; + +function normalizeFingerprint(input: string): string { + return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase(); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function generateSelfSignedCert(params: { + certPath: string; + keyPath: string; + log?: { info?: (msg: string) => void }; +}): Promise { + const certDir = path.dirname(params.certPath); + const keyDir = path.dirname(params.keyPath); + await ensureDir(certDir); + if (keyDir !== certDir) { + await ensureDir(keyDir); + } + await execFileAsync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-sha256", + "-days", + "3650", + "-nodes", + "-keyout", + params.keyPath, + "-out", + params.certPath, + "-subj", + "/CN=clawdbot-bridge", + ]); + await fs.chmod(params.keyPath, 0o600).catch(() => {}); + await fs.chmod(params.certPath, 0o600).catch(() => {}); + params.log?.info?.( + `bridge tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`, + ); +} + +export async function loadBridgeTlsRuntime( + cfg: BridgeTlsConfig | undefined, + log?: { info?: (msg: string) => void; warn?: (msg: string) => void }, +): Promise { + if (!cfg || cfg.enabled !== true) return { enabled: false, required: false }; + + const autoGenerate = cfg.autoGenerate !== false; + const baseDir = path.join(CONFIG_DIR, "bridge", "tls"); + const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "bridge-cert.pem")); + const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "bridge-key.pem")); + const caPath = cfg.caPath ? resolveUserPath(cfg.caPath) : undefined; + + const hasCert = await fileExists(certPath); + const hasKey = await fileExists(keyPath); + + if (!hasCert && !hasKey && autoGenerate) { + try { + await generateSelfSignedCert({ certPath, keyPath, log }); + } catch (err) { + return { + enabled: false, + required: true, + certPath, + keyPath, + error: `bridge tls: failed to generate cert (${String(err)})`, + }; + } + } + + if (!(await fileExists(certPath)) || !(await fileExists(keyPath))) { + return { + enabled: false, + required: true, + certPath, + keyPath, + error: "bridge tls: cert/key missing", + }; + } + + try { + const cert = await fs.readFile(certPath, "utf8"); + const key = await fs.readFile(keyPath, "utf8"); + const ca = caPath ? await fs.readFile(caPath, "utf8") : undefined; + const x509 = new X509Certificate(cert); + const fingerprintSha256 = normalizeFingerprint(x509.fingerprint256 ?? ""); + + if (!fingerprintSha256) { + return { + enabled: false, + required: true, + certPath, + keyPath, + caPath, + error: "bridge tls: unable to compute certificate fingerprint", + }; + } + + return { + enabled: true, + required: true, + certPath, + keyPath, + caPath, + fingerprintSha256, + tlsOptions: { + cert, + key, + ca, + minVersion: "TLSv1.2", + }, + }; + } catch (err) { + return { + enabled: false, + required: true, + certPath, + keyPath, + caPath, + error: `bridge tls: failed to load cert (${String(err)})`, + }; + } +} diff --git a/src/infra/bridge/server/types.ts b/src/infra/bridge/server/types.ts index f801e0a85..dada0ba3b 100644 --- a/src/infra/bridge/server/types.ts +++ b/src/infra/bridge/server/types.ts @@ -1,3 +1,5 @@ +import type { TlsOptions } from "node:tls"; + import type { NodePairingPendingRequest } from "../../node-pairing.js"; export type BridgeHelloFrame = { @@ -122,6 +124,7 @@ export type NodeBridgeClientInfo = { export type NodeBridgeServerOpts = { host: string; port: number; // 0 = ephemeral + tls?: TlsOptions; pairingBaseDir?: string; canvasHostPort?: number; canvasHostHost?: string; diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index eac680316..ac0420c08 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -73,7 +73,17 @@ async function writeJSONAtomic(filePath: string, value: unknown) { await fs.mkdir(dir, { recursive: true }); const tmp = `${filePath}.${randomUUID()}.tmp`; await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); + try { + await fs.chmod(tmp, 0o600); + } catch { + // best-effort; ignore on platforms without chmod + } await fs.rename(tmp, filePath); + try { + await fs.chmod(filePath, 0o600); + } catch { + // best-effort; ignore on platforms without chmod + } } function pruneExpiredPending( diff --git a/src/infra/widearea-dns.ts b/src/infra/widearea-dns.ts index 8cb687ec9..75f983d11 100644 --- a/src/infra/widearea-dns.ts +++ b/src/infra/widearea-dns.ts @@ -71,6 +71,8 @@ export type WideAreaBridgeZoneOpts = { displayName: string; tailnetIPv4: string; tailnetIPv6?: string; + bridgeTlsEnabled?: boolean; + bridgeTlsFingerprintSha256?: string; instanceLabel?: string; hostLabel?: string; tailnetDns?: string; @@ -91,6 +93,12 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string { if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) { txt.push(`gatewayPort=${opts.gatewayPort}`); } + if (opts.bridgeTlsEnabled) { + txt.push(`bridgeTls=1`); + if (opts.bridgeTlsFingerprintSha256) { + txt.push(`bridgeTlsSha256=${opts.bridgeTlsFingerprintSha256}`); + } + } if (opts.tailnetDns?.trim()) { txt.push(`tailnetDns=${opts.tailnetDns.trim()}`); }