From 054366dea42eed1c144e91509178c93960c335ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 17:47:13 +0100 Subject: [PATCH] fix(security): require explicit trust for first-time TLS pins --- .../java/ai/openclaw/android/MainViewModel.kt | 9 + .../java/ai/openclaw/android/NodeRuntime.kt | 43 ++- .../ai/openclaw/android/gateway/GatewayTls.kt | 68 ++++- .../android/node/ConnectionManager.kt | 4 +- .../ai/openclaw/android/ui/SettingsSheet.kt | 28 ++ .../android/node/ConnectionManagerTest.kt | 5 +- .../Gateway/GatewayConnectionController.swift | 274 ++++++++++++++---- .../Gateway/GatewaySettingsStore.swift | 63 +++- .../Gateway/GatewayTrustPromptAlert.swift | 42 +++ .../Onboarding/GatewayOnboardingView.swift | 1 + apps/ios/Sources/RootCanvas.swift | 1 + apps/ios/Sources/Settings/SettingsTab.swift | 7 +- .../GatewayConnectionSecurityTests.swift | 4 +- .../ios/Tests/GatewaySettingsStoreTests.swift | 72 +++++ docs/gateway/bonjour.md | 1 + docs/gateway/discovery.md | 3 +- 16 files changed, 549 insertions(+), 76 deletions(-) create mode 100644 apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index 1886e0f4be..d9123d1029 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -25,6 +25,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val statusText: StateFlow = runtime.statusText val serverName: StateFlow = runtime.serverName val remoteAddress: StateFlow = runtime.remoteAddress + val pendingGatewayTrust: StateFlow = runtime.pendingGatewayTrust val isForeground: StateFlow = runtime.isForeground val seamColorArgb: StateFlow = runtime.seamColorArgb val mainSessionKey: StateFlow = runtime.mainSessionKey @@ -145,6 +146,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.disconnect() } + fun acceptGatewayTrustPrompt() { + runtime.acceptGatewayTrustPrompt() + } + + fun declineGatewayTrustPrompt() { + runtime.declineGatewayTrustPrompt() + } + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { runtime.handleCanvasA2UIActionFromWebView(payloadJson) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index 965472c89b..aec192c25b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -15,6 +15,7 @@ import ai.openclaw.android.gateway.DeviceIdentityStore import ai.openclaw.android.gateway.GatewayDiscovery import ai.openclaw.android.gateway.GatewayEndpoint import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.android.gateway.probeGatewayTlsFingerprint import ai.openclaw.android.node.* import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction import ai.openclaw.android.voice.TalkModeManager @@ -166,12 +167,20 @@ class NodeRuntime(context: Context) { private lateinit var gatewayEventHandler: GatewayEventHandler + data class GatewayTrustPrompt( + val endpoint: GatewayEndpoint, + val fingerprintSha256: String, + ) + private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() private val _statusText = MutableStateFlow("Offline") val statusText: StateFlow = _statusText.asStateFlow() + private val _pendingGatewayTrust = MutableStateFlow(null) + val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow() + private val _mainSessionKey = MutableStateFlow("main") val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow() @@ -419,6 +428,12 @@ class NodeRuntime(context: Context) { val host = manualHost.value.trim() val port = manualPort.value if (host.isNotEmpty() && port in 1..65535) { + // Security: autoconnect only to previously trusted gateways (stored TLS pin). + if (!manualTls.value) return@collect + val stableId = GatewayEndpoint.manual(host = host, port = port).stableId + val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty() + if (storedFingerprint.isEmpty()) return@collect + didAutoConnect = true connect(GatewayEndpoint.manual(host = host, port = port)) } @@ -528,17 +543,42 @@ class NodeRuntime(context: Context) { } fun connect(endpoint: GatewayEndpoint) { + val tls = connectionManager.resolveTlsParams(endpoint) + if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) { + // First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect. + _statusText.value = "Verify gateway TLS fingerprint…" + scope.launch { + val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run { + _statusText.value = "Failed: can't read TLS fingerprint" + return@launch + } + _pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp) + } + return + } + connectedEndpoint = endpoint operatorStatusText = "Connecting…" nodeStatusText = "Connecting…" updateStatus() val token = prefs.loadGatewayToken() val password = prefs.loadGatewayPassword() - val tls = connectionManager.resolveTlsParams(endpoint) operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) } + fun acceptGatewayTrustPrompt() { + val prompt = _pendingGatewayTrust.value ?: return + _pendingGatewayTrust.value = null + prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256) + connect(prompt.endpoint) + } + + fun declineGatewayTrustPrompt() { + _pendingGatewayTrust.value = null + _statusText.value = "Offline" + } + private fun hasRecordAudioPermission(): Boolean { return ( ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == @@ -558,6 +598,7 @@ class NodeRuntime(context: Context) { fun disconnect() { connectedEndpoint = null + _pendingGatewayTrust.value = null operatorSession.disconnect() nodeSession.disconnect() } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt index dc17aa7329..1e43804d20 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt @@ -1,13 +1,20 @@ package ai.openclaw.android.gateway import android.annotation.SuppressLint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.InetSocketAddress import java.security.MessageDigest import java.security.SecureRandom import java.security.cert.CertificateException import java.security.cert.X509Certificate +import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HostnameVerifier import javax.net.ssl.SSLContext +import javax.net.ssl.SSLParameters import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.SNIHostName +import javax.net.ssl.SSLSocket import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -59,13 +66,72 @@ fun buildGatewayTlsConfig( val context = SSLContext.getInstance("TLS") context.init(null, arrayOf(trustManager), SecureRandom()) + val verifier = + if (expected != null || params.allowTOFU) { + // When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs). + HostnameVerifier { _, _ -> true } + } else { + HttpsURLConnection.getDefaultHostnameVerifier() + } return GatewayTlsConfig( sslSocketFactory = context.socketFactory, trustManager = trustManager, - hostnameVerifier = HostnameVerifier { _, _ -> true }, + hostnameVerifier = verifier, ) } +suspend fun probeGatewayTlsFingerprint( + host: String, + port: Int, + timeoutMs: Int = 3_000, +): String? { + val trimmedHost = host.trim() + if (trimmedHost.isEmpty()) return null + if (port !in 1..65535) return null + + return withContext(Dispatchers.IO) { + val trustAll = + @SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(trustAll), SecureRandom()) + + val socket = (context.socketFactory.createSocket() as SSLSocket) + try { + socket.soTimeout = timeoutMs + socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs) + + // Best-effort SNI for hostnames (avoid crashing on IP literals). + try { + if (trimmedHost.any { it.isLetter() }) { + val params = SSLParameters() + params.serverNames = listOf(SNIHostName(trimmedHost)) + socket.sslParameters = params + } + } catch (_: Throwable) { + // ignore + } + + socket.startHandshake() + val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null + sha256Hex(cert.encoded) + } catch (_: Throwable) { + null + } finally { + try { + socket.close() + } catch (_: Throwable) { + // ignore + } + } + } +} + private fun defaultTrustManager(): X509TrustManager { val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) factory.init(null as java.security.KeyStore?) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt index 33d5f9d531..d15d928e0a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -49,7 +49,7 @@ class ConnectionManager( return GatewayTlsParams( required = true, expectedFingerprint = null, - allowTOFU = true, + allowTOFU = false, stableId = stableId, ) } @@ -70,7 +70,7 @@ class ConnectionManager( return GatewayTlsParams( required = true, expectedFingerprint = null, - allowTOFU = true, + allowTOFU = false, stableId = stableId, ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index eb3d77860a..bb04c30108 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -42,6 +43,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -89,6 +91,7 @@ fun SettingsSheet(viewModel: MainViewModel) { val remoteAddress by viewModel.remoteAddress.collectAsState() val gateways by viewModel.gateways.collectAsState() val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } @@ -112,6 +115,31 @@ fun SettingsSheet(viewModel: MainViewModel) { } } + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\n" + + "Verify this SHA-256 fingerprint out-of-band before trusting:\n" + + prompt.fingerprintSha256, + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and connect") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } val commitWakeWords = { val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt index 3704f93bd5..534b90a212 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt @@ -49,7 +49,7 @@ class ConnectionManagerTest { ) assertNull(params?.expectedFingerprint) - assertEquals(true, params?.allowTOFU) + assertEquals(false, params?.allowTOFU) } @Test @@ -71,7 +71,6 @@ class ConnectionManagerTest { manualTlsEnabled = true, ) assertNull(on?.expectedFingerprint) - assertEquals(true, on?.allowTOFU) + assertEquals(false, on?.allowTOFU) } } - diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 579513e365..995e2f36d0 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -2,6 +2,7 @@ import AVFoundation import Contacts import CoreLocation import CoreMotion +import CryptoKit import EventKit import Foundation import OpenClawKit @@ -9,6 +10,7 @@ import Network import Observation import Photos import ReplayKit +import Security import Speech import SwiftUI import UIKit @@ -16,14 +18,27 @@ import UIKit @MainActor @Observable final class GatewayConnectionController { + struct TrustPrompt: Identifiable, Equatable { + let stableID: String + let gatewayName: String + let host: String + let port: Int + let fingerprintSha256: String + let isManual: Bool + + var id: String { self.stableID } + } + private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] private(set) var discoveryStatusText: String = "Idle" private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = [] + private(set) var pendingTrustPrompt: TrustPrompt? private let discovery = GatewayDiscoveryModel() private weak var appModel: NodeAppModel? private var didAutoConnect = false private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] + private var pendingTrustConnect: (url: URL, stableID: String, isManual: Bool)? init(appModel: NodeAppModel, startDiscovery: Bool = true) { self.appModel = appModel @@ -58,12 +73,11 @@ final class GatewayConnectionController { } func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { - await self.connectDiscoveredGateway(gateway, allowTOFU: true) + await self.connectDiscoveredGateway(gateway) } private func connectDiscoveredGateway( - _ gateway: GatewayDiscoveryModel.DiscoveredGateway, - allowTOFU: Bool) async + _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -73,21 +87,43 @@ final class GatewayConnectionController { // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return } - let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU) + let stableID = gateway.stableID + // Discovery is a LAN operation; refuse unauthenticated plaintext connects. + let tlsRequired = true + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + + guard gateway.tlsEnabled || stored != nil else { return } + + if tlsRequired, stored == nil { + guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true) + else { return } + guard let fp = await self.probeTLSFingerprint(url: url) else { return } + self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false) + self.pendingTrustPrompt = TrustPrompt( + stableID: stableID, + gatewayName: gateway.name, + host: target.host, + port: target.port, + fingerprintSha256: fp, + isManual: false) + self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" + return + } + + let tlsParams = stored.map { fp in + GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) + } + guard let url = self.buildGatewayURL( host: target.host, port: target.port, useTLS: tlsParams?.required == true) else { return } - GatewaySettingsStore.saveLastGatewayConnection( - host: target.host, - port: target.port, - useTLS: tlsParams?.required == true, - stableID: gateway.stableID) + GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true) self.didAutoConnect = true self.startAutoConnect( url: url, - gatewayStableID: gateway.stableID, + gatewayStableID: stableID, tls: tlsParams, token: token, password: password) @@ -102,19 +138,34 @@ final class GatewayConnectionController { guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) else { return } let stableID = self.manualStableID(host: host, port: resolvedPort) - let tlsParams = self.resolveManualTLSParams( - stableID: stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: host)) + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + if resolvedUseTLS, stored == nil { + guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return } + guard let fp = await self.probeTLSFingerprint(url: url) else { return } + self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true) + self.pendingTrustPrompt = TrustPrompt( + stableID: stableID, + gatewayName: "\(host):\(resolvedPort)", + host: host, + port: resolvedPort, + fingerprintSha256: fp, + isManual: true) + self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" + return + } + + let tlsParams = stored.map { fp in + GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) + } guard let url = self.buildGatewayURL( host: host, port: resolvedPort, useTLS: tlsParams?.required == true) else { return } - GatewaySettingsStore.saveLastGatewayConnection( + GatewaySettingsStore.saveLastGatewayConnectionManual( host: host, port: resolvedPort, - useTLS: tlsParams?.required == true, + useTLS: resolvedUseTLS && tlsParams != nil, stableID: stableID) self.didAutoConnect = true self.startAutoConnect( @@ -127,36 +178,63 @@ final class GatewayConnectionController { func connectLastKnown() async { guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return } + switch last { + case let .manual(host, port, useTLS, _): + await self.connectManual(host: host, port: port, useTLS: useTLS) + case let .discovered(stableID, _): + guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return } + await self.connectDiscoveredGateway(gateway) + } + } + + func clearPendingTrustPrompt() { + self.pendingTrustPrompt = nil + self.pendingTrustConnect = nil + } + + func acceptPendingTrustPrompt() async { + guard let pending = self.pendingTrustConnect, + let prompt = self.pendingTrustPrompt, + pending.stableID == prompt.stableID + else { return } + + GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID) + self.clearPendingTrustPrompt() + + if pending.isManual { + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: prompt.host, + port: prompt.port, + useTLS: true, + stableID: pending.stableID) + } else { + GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true) + } + let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - let resolvedUseTLS = last.useTLS - let tlsParams = self.resolveManualTLSParams( - stableID: last.stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: last.host)) - guard let url = self.buildGatewayURL( - host: last.host, - port: last.port, - useTLS: tlsParams?.required == true) - else { return } - if resolvedUseTLS != last.useTLS { - GatewaySettingsStore.saveLastGatewayConnection( - host: last.host, - port: last.port, - useTLS: resolvedUseTLS, - stableID: last.stableID) - } + let tlsParams = GatewayTLSParams( + required: true, + expectedFingerprint: prompt.fingerprintSha256, + allowTOFU: false, + storeKey: pending.stableID) + self.didAutoConnect = true self.startAutoConnect( - url: url, - gatewayStableID: last.stableID, + url: pending.url, + gatewayStableID: pending.stableID, tls: tlsParams, token: token, password: password) } + func declinePendingTrustPrompt() { + self.clearPendingTrustPrompt() + self.appModel?.gatewayStatusText = "Offline" + } + private func updateFromDiscovery() { let newGateways = self.discovery.gateways self.gateways = newGateways @@ -233,25 +311,30 @@ final class GatewayConnectionController { } if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { - let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host) - let tlsParams = self.resolveManualTLSParams( - stableID: lastKnown.stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: lastKnown.host)) - guard let url = self.buildGatewayURL( - host: lastKnown.host, - port: lastKnown.port, - useTLS: tlsParams?.required == true) - else { return } + if case let .manual(host, port, useTLS, stableID) = lastKnown { + let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host) + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + let tlsParams = stored.map { fp in + GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) + } + guard let url = self.buildGatewayURL( + host: host, + port: port, + useTLS: resolvedUseTLS && tlsParams != nil) + else { return } - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: lastKnown.stableID, - tls: tlsParams, - token: token, - password: password) - return + // Security: autoconnect only to previously trusted gateways (stored TLS pin). + guard tlsParams != nil else { return } + + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: stableID, + tls: tlsParams, + token: token, + password: password) + return + } } let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")? @@ -270,7 +353,7 @@ final class GatewayConnectionController { self.didAutoConnect = true Task { [weak self] in guard let self else { return } - await self.connectDiscoveredGateway(target, allowTOFU: false) + await self.connectDiscoveredGateway(target) } return } @@ -282,7 +365,7 @@ final class GatewayConnectionController { self.didAutoConnect = true Task { [weak self] in guard let self else { return } - await self.connectDiscoveredGateway(gateway, allowTOFU: false) + await self.connectDiscoveredGateway(gateway) } return } @@ -359,7 +442,7 @@ final class GatewayConnectionController { return GatewayTLSParams( required: true, expectedFingerprint: nil, - allowTOFU: allowTOFU, + allowTOFU: false, storeKey: stableID) } @@ -376,13 +459,22 @@ final class GatewayConnectionController { return GatewayTLSParams( required: true, expectedFingerprint: stored, - allowTOFU: stored == nil || allowTOFUReset, + allowTOFU: false, storeKey: stableID) } return nil } + private func probeTLSFingerprint(url: URL) async -> String? { + await withCheckedContinuation { continuation in + let probe = GatewayTLSFingerprintProbe(url: url, timeoutSeconds: 3) { fp in + continuation.resume(returning: fp) + } + probe.start() + } + } + private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { guard case let .service(name, type, domain, _) = endpoint else { return nil } let key = "\(domain)|\(type)|\(name)" @@ -692,3 +784,71 @@ extension GatewayConnectionController { } } #endif + +private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate { + private let url: URL + private let timeoutSeconds: Double + private let onComplete: (String?) -> Void + private var didFinish = false + private var session: URLSession? + private var task: URLSessionWebSocketTask? + + init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) { + self.url = url + self.timeoutSeconds = timeoutSeconds + self.onComplete = onComplete + } + + func start() { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = self.timeoutSeconds + config.timeoutIntervalForResource = self.timeoutSeconds + let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + self.session = session + let task = session.webSocketTask(with: self.url) + self.task = task + task.resume() + + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in + self?.finish(nil) + } + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + + let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust) + completionHandler(.cancelAuthenticationChallenge, nil) + self.finish(fp) + } + + private func finish(_ fingerprint: String?) { + objc_sync_enter(self) + defer { objc_sync_exit(self) } + guard !self.didFinish else { return } + self.didFinish = true + self.task?.cancel(with: .goingAway, reason: nil) + self.session?.invalidateAndCancel() + self.onComplete(fingerprint) + } + + private static func certificateFingerprint(_ trust: SecTrust) -> String? { + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let cert = chain.first + else { + return nil + } + let data = SecCertificateCopyData(cert) as Data + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index d227386523..11fbbc5f0c 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -13,6 +13,7 @@ enum GatewaySettingsStore { private static let manualPortDefaultsKey = "gateway.manual.port" private static let manualTlsDefaultsKey = "gateway.manual.tls" private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs" + private static let lastGatewayKindDefaultsKey = "gateway.last.kind" private static let lastGatewayHostDefaultsKey = "gateway.last.host" private static let lastGatewayPortDefaultsKey = "gateway.last.port" private static let lastGatewayTlsDefaultsKey = "gateway.last.tls" @@ -114,25 +115,73 @@ enum GatewaySettingsStore { account: self.gatewayPasswordAccount(instanceId: instanceId)) } - static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) { + enum LastGatewayConnection: Equatable { + case manual(host: String, port: Int, useTLS: Bool, stableID: String) + case discovered(stableID: String, useTLS: Bool) + + var stableID: String { + switch self { + case let .manual(_, _, _, stableID): + return stableID + case let .discovered(stableID, _): + return stableID + } + } + + var useTLS: Bool { + switch self { + case let .manual(_, _, useTLS, _): + return useTLS + case let .discovered(_, useTLS): + return useTLS + } + } + } + + private enum LastGatewayKind: String { + case manual + case discovered + } + + static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { let defaults = UserDefaults.standard + defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey) defaults.set(host, forKey: self.lastGatewayHostDefaultsKey) defaults.set(port, forKey: self.lastGatewayPortDefaultsKey) defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) } - static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? { + static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) { let defaults = UserDefaults.standard + defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) + defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) + defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) + } + + static func loadLastGatewayConnection() -> LastGatewayConnection? { + let defaults = UserDefaults.standard + let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !stableID.isEmpty else { return nil } + let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey) + let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual + + if kind == .discovered { + return .discovered(stableID: stableID, useTLS: useTLS) + } + let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey) - let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey) - let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil } - return (host: host, port: port, useTLS: useTLS, stableID: stableID) + // Back-compat: older builds persisted manual-style host/port without a kind marker. + guard !host.isEmpty, port > 0, port <= 65535 else { return nil } + return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID) } static func loadGatewayClientIdOverride(stableID: String) -> String? { diff --git a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift new file mode 100644 index 0000000000..f117ad9ea4 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct GatewayTrustPromptAlert: ViewModifier { + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + + private var promptBinding: Binding { + Binding( + get: { self.gatewayController.pendingTrustPrompt }, + set: { newValue in + if newValue == nil { + self.gatewayController.clearPendingTrustPrompt() + } + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Trust this gateway?"), + message: Text( + """ + First-time TLS connection. + + Verify this SHA-256 fingerprint out-of-band before trusting: + \(prompt.fingerprintSha256) + """), + primaryButton: .cancel(Text("Cancel")) { + self.gatewayController.declinePendingTrustPrompt() + }, + secondaryButton: .default(Text("Trust and connect")) { + Task { await self.gatewayController.acceptPendingTrustPrompt() } + }) + } + } +} + +extension View { + func gatewayTrustPromptAlert() -> some View { + self.modifier(GatewayTrustPromptAlert()) + } +} + diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index 18eac23e28..09c9e2429a 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -21,6 +21,7 @@ struct GatewayOnboardingView: View { } .navigationTitle("Connect Gateway") } + .gatewayTrustPromptAlert() } } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index d3da84cae8..514e1b4cc4 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -52,6 +52,7 @@ struct RootCanvas: View { CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) } } + .gatewayTrustPromptAlert() .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 6267f621c5..662a22cb04 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -376,6 +376,7 @@ struct SettingsTab: View { } } } + .gatewayTrustPromptAlert() } @ViewBuilder @@ -388,11 +389,13 @@ struct SettingsTab: View { .font(.footnote) .foregroundStyle(.secondary) - if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { + if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(), + case let .manual(host, port, _, _) = lastKnown + { Button { Task { await self.connectLastKnown() } } label: { - self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port) + self.lastKnownButtonLabel(host: host, port: port) } .disabled(self.connectingGatewayID != nil) .buttonStyle(.borderedProminent) diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index c05add2aed..066ccb1dd2 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -62,7 +62,7 @@ import Testing let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true) #expect(params?.expectedFingerprint == nil) - #expect(params?.allowTOFU == true) + #expect(params?.allowTOFU == false) } @Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async { @@ -77,6 +77,7 @@ import Testing defaults.removeObject(forKey: "gateway.last.port") defaults.removeObject(forKey: "gateway.last.tls") defaults.removeObject(forKey: "gateway.last.stableID") + defaults.removeObject(forKey: "gateway.last.kind") defaults.removeObject(forKey: "gateway.preferredStableID") defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID") @@ -102,4 +103,3 @@ import Testing #expect(controller._test_didAutoConnect() == false) } } - diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index cd9842239c..7e67ab84a9 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -124,4 +124,76 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain") #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain") } + + @Test func lastGateway_manualRoundTrip() { + let keys = [ + "gateway.last.kind", + "gateway.last.host", + "gateway.last.port", + "gateway.last.tls", + "gateway.last.stableID", + ] + let snapshot = snapshotDefaults(keys) + defer { restoreDefaults(snapshot) } + + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "example.com", + port: 443, + useTLS: true, + stableID: "manual|example.com|443") + + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443")) + } + + @Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() { + let keys = [ + "gateway.last.kind", + "gateway.last.host", + "gateway.last.port", + "gateway.last.tls", + "gateway.last.stableID", + ] + let snapshot = snapshotDefaults(keys) + defer { restoreDefaults(snapshot) } + + // Simulate a prior manual record that included host/port. + applyDefaults([ + "gateway.last.host": "10.0.0.99", + "gateway.last.port": 18789, + "gateway.last.tls": true, + "gateway.last.stableID": "manual|10.0.0.99|18789", + "gateway.last.kind": "manual", + ]) + + GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true) + + let defaults = UserDefaults.standard + #expect(defaults.object(forKey: "gateway.last.host") == nil) + #expect(defaults.object(forKey: "gateway.last.port") == nil) + #expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true)) + } + + @Test func lastGateway_backCompat_manualLoadsWhenKindMissing() { + let keys = [ + "gateway.last.kind", + "gateway.last.host", + "gateway.last.port", + "gateway.last.tls", + "gateway.last.stableID", + ] + let snapshot = snapshotDefaults(keys) + defer { restoreDefaults(snapshot) } + + applyDefaults([ + "gateway.last.kind": nil, + "gateway.last.host": "example.org", + "gateway.last.port": 18789, + "gateway.last.tls": false, + "gateway.last.stableID": "manual|example.org|18789", + ]) + + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) + } } diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index 0f5d287f8d..03643717d5 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -105,6 +105,7 @@ Security notes: - Bonjour/mDNS TXT records are **unauthenticated**. Clients must not treat TXT as authoritative routing. - Clients should route using the resolved service endpoint (SRV + A/AAAA). Treat `lanHost`, `tailnetDns`, `gatewayPort`, and `gatewayTlsSha256` as hints only. - TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin. +- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require explicit user confirmation before trusting a first-time fingerprint. ## Debugging on macOS diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md index 4bf7884d1c..af1144125d 100644 --- a/docs/gateway/discovery.md +++ b/docs/gateway/discovery.md @@ -72,7 +72,8 @@ Security notes: - Bonjour/mDNS TXT records are **unauthenticated**. Clients must treat TXT values as UX hints only. - Routing (host/port) should prefer the **resolved service endpoint** (SRV + A/AAAA) over TXT-provided `lanHost`, `tailnetDns`, or `gatewayPort`. -- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin. For first-time connections, require explicit user intent (TOFU or other out-of-band verification). +- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin. +- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification). Disable/override: