From bf89947a8e9ec5d278b71a8c438ce414dd04a2d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 12 Mar 2026 22:22:44 +0000 Subject: [PATCH] fix: switch pairing setup codes to bootstrap tokens --- CHANGELOG.md | 1 + .../java/ai/openclaw/app/MainViewModel.kt | 4 + .../main/java/ai/openclaw/app/NodeRuntime.kt | 39 ++++- .../main/java/ai/openclaw/app/SecurePrefs.kt | 34 +++- .../ai/openclaw/app/gateway/GatewaySession.kt | 38 ++++- .../ai/openclaw/app/ui/ConnectTabScreen.kt | 3 + .../openclaw/app/ui/GatewayConfigResolver.kt | 28 +++- .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 20 ++- .../java/ai/openclaw/app/SecurePrefsTest.kt | 15 ++ .../app/gateway/GatewaySessionInvokeTest.kt | 103 +++++++++++- .../app/ui/GatewayConfigResolverTest.kt | 43 ++++- .../Gateway/GatewayConnectConfig.swift | 1 + .../Gateway/GatewayConnectionController.swift | 12 ++ .../Gateway/GatewaySettingsStore.swift | 22 +++ .../Sources/Gateway/GatewaySetupCode.swift | 2 +- apps/ios/Sources/Model/NodeAppModel.swift | 9 ++ .../Onboarding/GatewayOnboardingView.swift | 12 ++ .../Onboarding/OnboardingWizardView.swift | 17 +- apps/ios/Sources/Settings/SettingsTab.swift | 15 ++ apps/ios/Tests/DeepLinkParserTests.swift | 27 ++-- .../Sources/OpenClaw/ControlChannel.swift | 2 + .../NodeMode/MacNodeModeCoordinator.swift | 1 + .../OpenClaw/OnboardingView+Pages.swift | 2 + .../Sources/OpenClaw/RemoteGatewayProbe.swift | 17 +- .../OnboardingRemoteAuthPromptTests.swift | 13 ++ .../Sources/OpenClawKit/DeepLinks.swift | 16 +- .../Sources/OpenClawKit/GatewayChannel.swift | 33 +++- .../Sources/OpenClawKit/GatewayErrors.swift | 2 + .../OpenClawKit/GatewayNodeSession.swift | 6 + .../DeepLinksSecurityTests.swift | 17 +- .../OpenClawKitTests/GatewayErrorsTests.swift | 14 ++ .../GatewayNodeSessionTests.swift | 1 + docs/channels/pairing.md | 2 +- docs/cli/qr.md | 7 +- extensions/device-pair/index.ts | 14 +- src/cli/qr-cli.test.ts | 24 +-- src/cli/qr-dashboard.integration.test.ts | 17 +- src/gateway/auth.ts | 9 +- src/gateway/client.test.ts | 21 +++ src/gateway/client.ts | 19 ++- src/gateway/protocol/connect-error-details.ts | 3 + src/gateway/protocol/schema/frames.ts | 1 + src/gateway/reconnect-gating.test.ts | 6 + .../server/ws-connection/auth-context.test.ts | 57 +++++++ .../server/ws-connection/auth-context.ts | 44 +++++ .../server/ws-connection/auth-messages.ts | 7 +- .../server/ws-connection/message-handler.ts | 40 ++++- src/infra/device-bootstrap.test.ts | 98 +++++++++++ src/infra/device-bootstrap.ts | 152 ++++++++++++++++++ src/pairing/setup-code.test.ts | 37 +++-- src/pairing/setup-code.ts | 12 +- src/plugin-sdk/device-pair.ts | 1 + ui/src/ui/gateway.ts | 1 + 53 files changed, 1035 insertions(+), 106 deletions(-) create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift create mode 100644 src/infra/device-bootstrap.test.ts create mode 100644 src/infra/device-bootstrap.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fad88abc..d001a9b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc. - Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc. +- Security/device pairing: switch `/pair` and `openclaw qr` setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua. - Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (`GHSA-99qw-6mr3-36qr`)(#44174) Thanks @lintsinghua and @vincentkoc. - Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt. - Kimi Coding/provider config: respect explicit `models.providers["kimi-coding"].baseUrl` when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index a1b6ba3d3..128527144 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -116,6 +116,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setGatewayToken(value) } + fun setGatewayBootstrapToken(value: String) { + runtime.setGatewayBootstrapToken(value) + } + fun setGatewayPassword(value: String) { runtime.setGatewayPassword(value) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index c4e5f6a5b..bd94edef9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -503,6 +503,7 @@ class NodeRuntime(context: Context) { val gatewayToken: StateFlow = prefs.gatewayToken val onboardingCompleted: StateFlow = prefs.onboardingCompleted fun setGatewayToken(value: String) = prefs.setGatewayToken(value) + fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value) fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId @@ -698,10 +699,25 @@ class NodeRuntime(context: Context) { operatorStatusText = "Connecting…" updateStatus() val token = prefs.loadGatewayToken() + val bootstrapToken = prefs.loadGatewayBootstrapToken() 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) + operatorSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildOperatorConnectOptions(), + tls, + ) + nodeSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildNodeConnectOptions(), + tls, + ) operatorSession.reconnect() nodeSession.reconnect() } @@ -726,9 +742,24 @@ class NodeRuntime(context: Context) { nodeStatusText = "Connecting…" updateStatus() val token = prefs.loadGatewayToken() + val bootstrapToken = prefs.loadGatewayBootstrapToken() val password = prefs.loadGatewayPassword() - operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) - nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) + operatorSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildOperatorConnectOptions(), + tls, + ) + nodeSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildNodeConnectOptions(), + tls, + ) } fun acceptGatewayTrustPrompt() { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index b7e72ee41..a1aabeb1b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -15,7 +15,10 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import java.util.UUID -class SecurePrefs(context: Context) { +class SecurePrefs( + context: Context, + private val securePrefsOverride: SharedPreferences? = null, +) { companion object { val defaultWakeWords: List = listOf("openclaw", "claude") private const val displayNameKey = "node.displayName" @@ -35,7 +38,7 @@ class SecurePrefs(context: Context) { .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() } - private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) } + private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) } private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) val instanceId: StateFlow = _instanceId @@ -76,6 +79,9 @@ class SecurePrefs(context: Context) { private val _gatewayToken = MutableStateFlow("") val gatewayToken: StateFlow = _gatewayToken + private val _gatewayBootstrapToken = MutableStateFlow("") + val gatewayBootstrapToken: StateFlow = _gatewayBootstrapToken + private val _onboardingCompleted = MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false)) val onboardingCompleted: StateFlow = _onboardingCompleted @@ -165,6 +171,10 @@ class SecurePrefs(context: Context) { saveGatewayPassword(value) } + fun setGatewayBootstrapToken(value: String) { + saveGatewayBootstrapToken(value) + } + fun setOnboardingCompleted(value: Boolean) { plainPrefs.edit { putBoolean("onboarding.completed", value) } _onboardingCompleted.value = value @@ -193,6 +203,26 @@ class SecurePrefs(context: Context) { securePrefs.edit { putString(key, token.trim()) } } + fun loadGatewayBootstrapToken(): String? { + val key = "gateway.bootstrapToken.${_instanceId.value}" + val stored = + _gatewayBootstrapToken.value.trim().ifEmpty { + val persisted = securePrefs.getString(key, null)?.trim().orEmpty() + if (persisted.isNotEmpty()) { + _gatewayBootstrapToken.value = persisted + } + persisted + } + return stored.takeIf { it.isNotEmpty() } + } + + fun saveGatewayBootstrapToken(token: String) { + val key = "gateway.bootstrapToken.${_instanceId.value}" + val trimmed = token.trim() + securePrefs.edit { putString(key, trimmed) } + _gatewayBootstrapToken.value = trimmed + } + fun loadGatewayPassword(): String? { val key = "gateway.password.${_instanceId.value}" val stored = securePrefs.getString(key, null)?.trim() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index aee47eaad..6f6daa321 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -95,6 +95,7 @@ class GatewaySession( private data class DesiredConnection( val endpoint: GatewayEndpoint, val token: String?, + val bootstrapToken: String?, val password: String?, val options: GatewayConnectOptions, val tls: GatewayTlsParams?, @@ -107,11 +108,12 @@ class GatewaySession( fun connect( endpoint: GatewayEndpoint, token: String?, + bootstrapToken: String?, password: String?, options: GatewayConnectOptions, tls: GatewayTlsParams? = null, ) { - desired = DesiredConnection(endpoint, token, password, options, tls) + desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls) if (job == null) { job = scope.launch(Dispatchers.IO) { runLoop() } } @@ -219,6 +221,7 @@ class GatewaySession( private inner class Connection( private val endpoint: GatewayEndpoint, private val token: String?, + private val bootstrapToken: String?, private val password: String?, private val options: GatewayConnectOptions, private val tls: GatewayTlsParams?, @@ -346,9 +349,18 @@ class GatewaySession( val identity = identityStore.loadOrCreate() val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() + val trimmedBootstrapToken = bootstrapToken?.trim().orEmpty() // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() - val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) + val authBootstrapToken = if (authToken.isBlank()) trimmedBootstrapToken else "" + val payload = + buildConnectParams( + identity = identity, + connectNonce = connectNonce, + authToken = authToken, + authBootstrapToken = authBootstrapToken, + authPassword = password?.trim(), + ) val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS) if (!res.ok) { val msg = res.error?.message ?: "connect failed" @@ -381,6 +393,7 @@ class GatewaySession( identity: DeviceIdentity, connectNonce: String, authToken: String, + authBootstrapToken: String, authPassword: String?, ): JsonObject { val client = options.client @@ -404,6 +417,10 @@ class GatewaySession( buildJsonObject { put("token", JsonPrimitive(authToken)) } + authBootstrapToken.isNotEmpty() -> + buildJsonObject { + put("bootstrapToken", JsonPrimitive(authBootstrapToken)) + } password.isNotEmpty() -> buildJsonObject { put("password", JsonPrimitive(password)) @@ -420,7 +437,12 @@ class GatewaySession( role = options.role, scopes = options.scopes, signedAtMs = signedAtMs, - token = if (authToken.isNotEmpty()) authToken else null, + token = + when { + authToken.isNotEmpty() -> authToken + authBootstrapToken.isNotEmpty() -> authBootstrapToken + else -> null + }, nonce = connectNonce, platform = client.platform, deviceFamily = client.deviceFamily, @@ -622,7 +644,15 @@ class GatewaySession( } private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) { - val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls) + val conn = + Connection( + target.endpoint, + target.token, + target.bootstrapToken, + target.password, + target.options, + target.tls, + ) currentConnection = conn try { conn.connect() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 4b8ac2c8e..5391ff78f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -200,8 +200,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) { viewModel.setManualHost(config.host) viewModel.setManualPort(config.port) viewModel.setManualTls(config.tls) + viewModel.setGatewayBootstrapToken(config.bootstrapToken) if (config.token.isNotBlank()) { viewModel.setGatewayToken(config.token) + } else if (config.bootstrapToken.isNotBlank()) { + viewModel.setGatewayToken("") } viewModel.setGatewayPassword(config.password) viewModel.connectManual() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 93b4fc1bb..9ca5687e5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -1,8 +1,8 @@ package ai.openclaw.app.ui -import androidx.core.net.toUri import java.util.Base64 import java.util.Locale +import java.net.URI import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -18,6 +18,7 @@ internal data class GatewayEndpointConfig( internal data class GatewaySetupCode( val url: String, + val bootstrapToken: String?, val token: String?, val password: String?, ) @@ -26,6 +27,7 @@ internal data class GatewayConnectConfig( val host: String, val port: Int, val tls: Boolean, + val bootstrapToken: String, val token: String, val password: String, ) @@ -44,12 +46,26 @@ internal fun resolveGatewayConnectConfig( if (useSetupCode) { val setup = decodeGatewaySetupCode(setupCode) ?: return null val parsed = parseGatewayEndpoint(setup.url) ?: return null + val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty() + val sharedToken = + when { + !setup.token.isNullOrBlank() -> setup.token.trim() + setupBootstrapToken.isNotEmpty() -> "" + else -> fallbackToken.trim() + } + val sharedPassword = + when { + !setup.password.isNullOrBlank() -> setup.password.trim() + setupBootstrapToken.isNotEmpty() -> "" + else -> fallbackPassword.trim() + } return GatewayConnectConfig( host = parsed.host, port = parsed.port, tls = parsed.tls, - token = setup.token ?: fallbackToken.trim(), - password = setup.password ?: fallbackPassword.trim(), + bootstrapToken = setupBootstrapToken, + token = sharedToken, + password = sharedPassword, ) } @@ -59,6 +75,7 @@ internal fun resolveGatewayConnectConfig( host = parsed.host, port = parsed.port, tls = parsed.tls, + bootstrapToken = "", token = fallbackToken.trim(), password = fallbackPassword.trim(), ) @@ -69,7 +86,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { if (raw.isEmpty()) return null val normalized = if (raw.contains("://")) raw else "https://$raw" - val uri = normalized.toUri() + val uri = runCatching { URI(normalized) }.getOrNull() ?: return null val host = uri.host?.trim().orEmpty() if (host.isEmpty()) return null @@ -104,9 +121,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { val obj = parseJsonObject(decoded) ?: return null val url = jsonField(obj, "url").orEmpty() if (url.isEmpty()) return null + val bootstrapToken = jsonField(obj, "bootstrapToken") val token = jsonField(obj, "token") val password = jsonField(obj, "password") - GatewaySetupCode(url = url, token = token, password = password) + GatewaySetupCode(url = url, bootstrapToken = bootstrapToken, token = token, password = password) } catch (_: IllegalArgumentException) { null } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 8810ea93f..dc33bdb68 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -772,8 +772,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { return@Button } gatewayUrl = parsedSetup.url - parsedSetup.token?.let { viewModel.setGatewayToken(it) } - gatewayPassword = parsedSetup.password.orEmpty() + viewModel.setGatewayBootstrapToken(parsedSetup.bootstrapToken.orEmpty()) + val sharedToken = parsedSetup.token.orEmpty().trim() + val password = parsedSetup.password.orEmpty().trim() + if (sharedToken.isNotEmpty()) { + viewModel.setGatewayToken(sharedToken) + } else if (!parsedSetup.bootstrapToken.isNullOrBlank()) { + viewModel.setGatewayToken("") + } + gatewayPassword = password + if (password.isEmpty() && !parsedSetup.bootstrapToken.isNullOrBlank()) { + viewModel.setGatewayPassword("") + } } else { val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) @@ -782,6 +792,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { return@Button } gatewayUrl = parsedGateway.displayUrl + viewModel.setGatewayBootstrapToken("") } step = OnboardingStep.Permissions }, @@ -850,8 +861,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { viewModel.setManualHost(parsed.host) viewModel.setManualPort(parsed.port) viewModel.setManualTls(parsed.tls) + if (gatewayInputMode == GatewayInputMode.Manual) { + viewModel.setGatewayBootstrapToken("") + } if (token.isNotEmpty()) { viewModel.setGatewayToken(token) + } else { + viewModel.setGatewayToken("") } viewModel.setGatewayPassword(password) viewModel.connectManual() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt index cd72bf75d..1ef860e29 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt @@ -20,4 +20,19 @@ class SecurePrefsTest { assertEquals(LocationMode.WhileUsing, prefs.locationMode.value) assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null)) } + + @Test + fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() { + val context = RuntimeEnvironment.getApplication() + val securePrefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + val prefs = SecurePrefs(context, securePrefsOverride = securePrefs) + + prefs.setGatewayToken("shared-token") + prefs.setGatewayBootstrapToken("bootstrap-token") + + assertEquals("shared-token", prefs.loadGatewayToken()) + assertEquals("bootstrap-token", prefs.loadGatewayBootstrapToken()) + assertEquals("bootstrap-token", prefs.gatewayBootstrapToken.value) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index a3f301498..24f149114 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -46,6 +46,7 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { private data class NodeHarness( val session: GatewaySession, val sessionJob: Job, + val deviceAuthStore: InMemoryDeviceAuthStore, ) private data class InvokeScenarioResult( @@ -56,6 +57,93 @@ private data class InvokeScenarioResult( @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GatewaySessionInvokeTest { + @Test + fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectAuth = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectAuth.isCompleted) { + connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() } + assertEquals("bootstrap-token", auth?.get("bootstrapToken")?.jsonPrimitive?.content) + assertNull(auth?.get("token")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_prefersStoredDeviceTokenOverBootstrapToken() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectAuth = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectAuth.isCompleted) { + connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + harness.deviceAuthStore.saveToken(deviceId, "node", "device-token") + + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() } + assertEquals("device-token", auth?.get("token")?.jsonPrimitive?.content) + assertNull(auth?.get("bootstrapToken")) + } finally { + shutdownHarness(harness, server) + } + } + @Test fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { val handshakeOrigin = AtomicReference(null) @@ -182,11 +270,12 @@ class GatewaySessionInvokeTest { ): NodeHarness { val app = RuntimeEnvironment.getApplication() val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() val session = GatewaySession( scope = CoroutineScope(sessionJob + Dispatchers.Default), identityStore = DeviceIdentityStore(app), - deviceAuthStore = InMemoryDeviceAuthStore(), + deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> if (!connected.isCompleted) connected.complete(Unit) }, @@ -197,10 +286,15 @@ class GatewaySessionInvokeTest { onInvoke = onInvoke, ) - return NodeHarness(session = session, sessionJob = sessionJob) + return NodeHarness(session = session, sessionJob = sessionJob, deviceAuthStore = deviceAuthStore) } - private suspend fun connectNodeSession(session: GatewaySession, port: Int) { + private suspend fun connectNodeSession( + session: GatewaySession, + port: Int, + token: String? = "test-token", + bootstrapToken: String? = null, + ) { session.connect( endpoint = GatewayEndpoint( @@ -210,7 +304,8 @@ class GatewaySessionInvokeTest { port = port, tlsEnabled = false, ), - token = "test-token", + token = token, + bootstrapToken = bootstrapToken, password = null, options = GatewayConnectOptions( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index 72738843f..a4eef3b9b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -8,7 +8,8 @@ import org.junit.Test class GatewayConfigResolverTest { @Test fun resolveScannedSetupCodeAcceptsRawSetupCode() { - val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""") + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") val resolved = resolveScannedSetupCode(setupCode) @@ -17,7 +18,8 @@ class GatewayConfigResolverTest { @Test fun resolveScannedSetupCodeAcceptsQrJsonPayload() { - val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""") + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") val qrJson = """ { @@ -53,6 +55,43 @@ class GatewayConfigResolverTest { assertNull(resolved) } + @Test + fun decodeGatewaySetupCodeParsesBootstrapToken() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + + val decoded = decodeGatewaySetupCode(setupCode) + + assertEquals("wss://gateway.example:18789", decoded?.url) + assertEquals("bootstrap-1", decoded?.bootstrapToken) + assertNull(decoded?.token) + assertNull(decoded?.password) + } + + @Test + fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHost = "", + manualPort = "", + manualTls = true, + fallbackToken = "shared-token", + fallbackPassword = "shared-password", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(true, resolved?.tls) + assertEquals("bootstrap-1", resolved?.bootstrapToken) + assertNull(resolved?.token?.takeIf { it.isNotEmpty() }) + assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) + } + private fun encodeSetupCode(payloadJson: String): String { return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) } diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift index 7f4e93380..0abea0e31 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift @@ -14,6 +14,7 @@ struct GatewayConnectConfig: Sendable { let stableID: String let tls: GatewayTLSParams? let token: String? + let bootstrapToken: String? let password: String? let nodeOptions: GatewayConnectOptions diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 259768a4d..dc94f3d07 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -101,6 +101,7 @@ final class GatewayConnectionController { return "Missing instanceId (node.instanceId). Try restarting the app." } let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. @@ -151,6 +152,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) return nil } @@ -163,6 +165,7 @@ final class GatewayConnectionController { let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) @@ -203,6 +206,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) } @@ -229,6 +233,7 @@ final class GatewayConnectionController { stableID: cfg.stableID, tls: cfg.tls, token: cfg.token, + bootstrapToken: cfg.bootstrapToken, password: cfg.password, nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) appModel.applyGatewayConnectConfig(refreshedConfig) @@ -261,6 +266,7 @@ final class GatewayConnectionController { let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let tlsParams = GatewayTLSParams( required: true, @@ -274,6 +280,7 @@ final class GatewayConnectionController { gatewayStableID: pending.stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) } @@ -319,6 +326,7 @@ final class GatewayConnectionController { guard !instanceId.isEmpty else { return } let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) if manualEnabled { @@ -353,6 +361,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) return } @@ -379,6 +388,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) return } @@ -448,6 +458,7 @@ final class GatewayConnectionController { gatewayStableID: String, tls: GatewayTLSParams?, token: String?, + bootstrapToken: String?, password: String?) { guard let appModel else { return } @@ -463,6 +474,7 @@ final class GatewayConnectionController { stableID: gatewayStableID, tls: tls, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions) appModel.applyGatewayConnectConfig(cfg) diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 37c039d69..92dc71259 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -104,6 +104,21 @@ enum GatewaySettingsStore { account: self.gatewayTokenAccount(instanceId: instanceId)) } + static func loadGatewayBootstrapToken(instanceId: String) -> String? { + let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId) + let token = KeychainStore.loadString(service: self.gatewayService, account: account)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if token?.isEmpty == false { return token } + return nil + } + + static func saveGatewayBootstrapToken(_ token: String, instanceId: String) { + _ = KeychainStore.saveString( + token, + service: self.gatewayService, + account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) + } + static func loadGatewayPassword(instanceId: String) -> String? { KeychainStore.loadString( service: self.gatewayService, @@ -278,6 +293,9 @@ enum GatewaySettingsStore { _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayTokenAccount(instanceId: trimmed)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayBootstrapTokenAccount(instanceId: trimmed)) _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayPasswordAccount(instanceId: trimmed)) @@ -331,6 +349,10 @@ enum GatewaySettingsStore { "gateway-token.\(instanceId)" } + private static func gatewayBootstrapTokenAccount(instanceId: String) -> String { + "gateway-bootstrap-token.\(instanceId)" + } + private static func gatewayPasswordAccount(instanceId: String) -> String { "gateway-password.\(instanceId)" } diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift index 8ccbab42d..d52ca0235 100644 --- a/apps/ios/Sources/Gateway/GatewaySetupCode.swift +++ b/apps/ios/Sources/Gateway/GatewaySetupCode.swift @@ -5,6 +5,7 @@ struct GatewaySetupPayload: Codable { var host: String? var port: Int? var tls: Bool? + var bootstrapToken: String? var token: String? var password: String? } @@ -39,4 +40,3 @@ enum GatewaySetupCode { return String(data: data, encoding: .utf8) } } - diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index ad21503f5..4c0ab81f1 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1680,6 +1680,7 @@ extension NodeAppModel { gatewayStableID: String, tls: GatewayTLSParams?, token: String?, + bootstrapToken: String?, password: String?, connectOptions: GatewayConnectOptions) { @@ -1692,6 +1693,7 @@ extension NodeAppModel { stableID: stableID, tls: tls, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions) self.prepareForGatewayConnect(url: url, stableID: effectiveStableID) @@ -1699,6 +1701,7 @@ extension NodeAppModel { url: url, stableID: effectiveStableID, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions, sessionBox: sessionBox) @@ -1706,6 +1709,7 @@ extension NodeAppModel { url: url, stableID: effectiveStableID, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions, sessionBox: sessionBox) @@ -1721,6 +1725,7 @@ extension NodeAppModel { gatewayStableID: cfg.stableID, tls: cfg.tls, token: cfg.token, + bootstrapToken: cfg.bootstrapToken, password: cfg.password, connectOptions: cfg.nodeOptions) } @@ -1801,6 +1806,7 @@ private extension NodeAppModel { url: URL, stableID: String, token: String?, + bootstrapToken: String?, password: String?, nodeOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?) @@ -1838,6 +1844,7 @@ private extension NodeAppModel { try await self.operatorGateway.connect( url: url, token: token, + bootstrapToken: bootstrapToken, password: password, connectOptions: operatorOptions, sessionBox: sessionBox, @@ -1896,6 +1903,7 @@ private extension NodeAppModel { url: URL, stableID: String, token: String?, + bootstrapToken: String?, password: String?, nodeOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?) @@ -1944,6 +1952,7 @@ private extension NodeAppModel { try await self.nodeGateway.connect( url: url, token: token, + bootstrapToken: bootstrapToken, password: password, connectOptions: currentOptions, sessionBox: sessionBox, diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index b8b6e2677..f160b37d7 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -275,9 +275,21 @@ private struct ManualEntryStep: View { if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + self.manualToken = "" } if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + self.manualPassword = "" + } + + let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedInstanceId.isEmpty { + let trimmedBootstrapToken = + payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId) } self.setupStatusText = "Setup code applied." diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 4cefeb77e..060b398eb 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -642,11 +642,17 @@ struct OnboardingWizardView: View { self.manualHost = link.host self.manualPort = link.port self.manualTLS = link.tls - if let token = link.token { + let trimmedBootstrapToken = link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) + self.saveGatewayBootstrapToken(trimmedBootstrapToken) + if let token = link.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { self.gatewayToken = token + } else if trimmedBootstrapToken?.isEmpty == false { + self.gatewayToken = "" } - if let password = link.password { + if let password = link.password?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty { self.gatewayPassword = password + } else if trimmedBootstrapToken?.isEmpty == false { + self.gatewayPassword = "" } self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) self.showQRScanner = false @@ -794,6 +800,13 @@ struct OnboardingWizardView: View { GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) } + private func saveGatewayBootstrapToken(_ token: String?) { + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInstanceId.isEmpty else { return } + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId) + } + private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { self.connectingGatewayID = gateway.id self.issue = .none diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 7aa79fa24..3dec2fa77 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -767,12 +767,22 @@ struct SettingsTab: View { } let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBootstrapToken = + payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId) + } if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) self.gatewayToken = trimmedToken if !trimmedInstanceId.isEmpty { GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) } + } else if !trimmedBootstrapToken.isEmpty { + self.gatewayToken = "" + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayToken("", instanceId: trimmedInstanceId) + } } if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) @@ -780,6 +790,11 @@ struct SettingsTab: View { if !trimmedInstanceId.isEmpty { GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) } + } else if !trimmedBootstrapToken.isEmpty { + self.gatewayPassword = "" + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId) + } } return true diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index 7f24aa3e3..bac3288ad 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -86,7 +86,13 @@ private func agentAction( string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")! #expect( DeepLinkParser.parse(url) == .gateway( - .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def"))) + .init( + host: "openclaw.local", + port: 18789, + tls: true, + bootstrapToken: nil, + token: "abc", + password: "def"))) } @Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() { @@ -102,14 +108,15 @@ private func agentAction( } @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { - let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"# + let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == .init( host: "gateway.example.com", port: 443, tls: true, - token: "tok", + bootstrapToken: "tok", + token: nil, password: "pw")) } @@ -118,38 +125,40 @@ private func agentAction( } @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { - let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"# + let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == .init( host: "gateway.example.com", port: 443, tls: true, - token: "tok", + bootstrapToken: "tok", + token: nil, password: nil)) } @Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() { - let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == nil) } @Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() { - let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == nil) } @Test func parseGatewaySetupCodeAllowsLoopbackWs() { - let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == .init( host: "127.0.0.1", port: 18789, tls: false, - token: "tok", + bootstrapToken: "tok", + token: nil, password: nil)) } } diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index c4472f8f4..607aab479 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -324,6 +324,8 @@ final class ControlChannel { switch source { case .deviceToken: return "Auth: device token (paired device)" + case .bootstrapToken: + return "Auth: bootstrap token (setup code)" case .sharedToken: return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" case .password: diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index fa216d09c..5e093c49e 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -77,6 +77,7 @@ final class MacNodeModeCoordinator { try await self.session.connect( url: config.url, token: config.token, + bootstrapToken: nil, password: config.password, connectOptions: connectOptions, sessionBox: sessionBox, diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 0beeb2bdc..f35e4e4c4 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -508,6 +508,8 @@ extension OnboardingView { return ("exclamationmark.triangle.fill", .orange) case .gatewayTokenNotConfigured: return ("wrench.and.screwdriver.fill", .orange) + case .setupCodeExpired: + return ("qrcode.viewfinder", .orange) case .passwordRequired: return ("lock.slash.fill", .orange) case .pairingRequired: diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift index f878d0f5e..7073ad81d 100644 --- a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift +++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift @@ -6,6 +6,7 @@ enum RemoteGatewayAuthIssue: Equatable { case tokenRequired case tokenMismatch case gatewayTokenNotConfigured + case setupCodeExpired case passwordRequired case pairingRequired @@ -20,6 +21,8 @@ enum RemoteGatewayAuthIssue: Equatable { self = .tokenMismatch case .authTokenNotConfigured: self = .gatewayTokenNotConfigured + case .authBootstrapTokenInvalid: + self = .setupCodeExpired case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured: self = .passwordRequired case .pairingRequired: @@ -33,7 +36,7 @@ enum RemoteGatewayAuthIssue: Equatable { switch self { case .tokenRequired, .tokenMismatch: true - case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired: + case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired: false } } @@ -46,6 +49,8 @@ enum RemoteGatewayAuthIssue: Equatable { "That token did not match the gateway" case .gatewayTokenNotConfigured: "This gateway host needs token setup" + case .setupCodeExpired: + "This setup code is no longer valid" case .passwordRequired: "This gateway is using unsupported auth" case .pairingRequired: @@ -61,6 +66,8 @@ enum RemoteGatewayAuthIssue: Equatable { "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again." case .gatewayTokenNotConfigured: "This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway." + case .setupCodeExpired: + "Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again." case .passwordRequired: "This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry." case .pairingRequired: @@ -72,6 +79,8 @@ enum RemoteGatewayAuthIssue: Equatable { switch self { case .tokenRequired, .gatewayTokenNotConfigured: "No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`." + case .setupCodeExpired: + nil case .pairingRequired: "If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`." case .tokenMismatch, .passwordRequired: @@ -87,6 +96,8 @@ enum RemoteGatewayAuthIssue: Equatable { "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host." case .gatewayTokenNotConfigured: "This gateway has token auth enabled, but no gateway.auth.token is configured on the host." + case .setupCodeExpired: + "Setup code expired or already used. Scan a fresh setup code, then try again." case .passwordRequired: "This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet." case .pairingRequired: @@ -108,6 +119,8 @@ struct RemoteGatewayProbeSuccess: Equatable { switch self.authSource { case .some(.deviceToken): "Connected via paired device" + case .some(.bootstrapToken): + "Connected with setup code" case .some(.sharedToken): "Connected with gateway token" case .some(.password): @@ -121,6 +134,8 @@ struct RemoteGatewayProbeSuccess: Equatable { switch self.authSource { case .some(.deviceToken): "This Mac used a stored device token. New or unpaired devices may still need the gateway token." + case .some(.bootstrapToken): + "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth." case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil: nil } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift index d33cff562..00f3e7047 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift @@ -17,6 +17,10 @@ struct OnboardingRemoteAuthPromptTests { message: "token not configured", detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue, canRetryWithDeviceToken: false) + let bootstrapInvalid = GatewayConnectAuthError( + message: "setup code expired", + detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue, + canRetryWithDeviceToken: false) let passwordMissing = GatewayConnectAuthError( message: "password missing", detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue, @@ -33,6 +37,7 @@ struct OnboardingRemoteAuthPromptTests { #expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired) #expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch) #expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured) + #expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired) #expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired) #expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired) #expect(RemoteGatewayAuthIssue(error: unknown) == nil) @@ -88,6 +93,11 @@ struct OnboardingRemoteAuthPromptTests { remoteToken: "", remoteTokenUnsupported: false, authIssue: .gatewayTokenNotConfigured) == false) + #expect(OnboardingView.shouldShowRemoteTokenField( + showAdvancedConnection: false, + remoteToken: "", + remoteTokenUnsupported: false, + authIssue: .setupCodeExpired) == false) #expect(OnboardingView.shouldShowRemoteTokenField( showAdvancedConnection: false, remoteToken: "", @@ -106,11 +116,14 @@ struct OnboardingRemoteAuthPromptTests { @Test func `paired device success copy explains auth source`() { let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken) + let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken) let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken) let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none) #expect(pairedDevice.title == "Connected via paired device") #expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.") + #expect(bootstrap.title == "Connected with setup code") + #expect(bootstrap.detail == "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.") #expect(sharedToken.title == "Connected with gateway token") #expect(sharedToken.detail == nil) #expect(noAuth.title == "Remote gateway ready") diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 20b376166..5f1440ccb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -9,13 +9,15 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { public let host: String public let port: Int public let tls: Bool + public let bootstrapToken: String? public let token: String? public let password: String? - public init(host: String, port: Int, tls: Bool, token: String?, password: String?) { + public init(host: String, port: Int, tls: Bool, bootstrapToken: String?, token: String?, password: String?) { self.host = host self.port = port self.tls = tls + self.bootstrapToken = bootstrapToken self.token = token self.password = password } @@ -25,7 +27,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return URL(string: "\(scheme)://\(self.host):\(self.port)") } - /// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`). + /// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`). public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? { guard let data = Self.decodeBase64Url(code) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } @@ -41,9 +43,16 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return nil } let port = parsed.port ?? (tls ? 443 : 18789) + let bootstrapToken = json["bootstrapToken"] as? String let token = json["token"] as? String let password = json["password"] as? String - return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password) + return GatewayConnectDeepLink( + host: hostname, + port: port, + tls: tls, + bootstrapToken: bootstrapToken, + token: token, + password: password) } private static func decodeBase64Url(_ input: String) -> Data? { @@ -140,6 +149,7 @@ public enum DeepLinkParser { host: hostParam, port: port, tls: tls, + bootstrapToken: nil, token: query["token"], password: query["password"])) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 8a2f4e4bb..b78e3813b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -112,6 +112,7 @@ public struct GatewayConnectOptions: Sendable { public enum GatewayAuthSource: String, Sendable { case deviceToken = "device-token" case sharedToken = "shared-token" + case bootstrapToken = "bootstrap-token" case password = "password" case none = "none" } @@ -131,6 +132,12 @@ private let defaultOperatorConnectScopes: [String] = [ "operator.pairing", ] +private extension String { + var nilIfEmpty: String? { + self.isEmpty ? nil : self + } +} + private enum GatewayConnectErrorCodes { static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue @@ -154,6 +161,7 @@ public actor GatewayChannelActor { private var connectWaiters: [CheckedContinuation] = [] private var url: URL private var token: String? + private var bootstrapToken: String? private var password: String? private let session: WebSocketSessioning private var backoffMs: Double = 500 @@ -185,6 +193,7 @@ public actor GatewayChannelActor { public init( url: URL, token: String?, + bootstrapToken: String? = nil, password: String? = nil, session: WebSocketSessionBox? = nil, pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil, @@ -193,6 +202,7 @@ public actor GatewayChannelActor { { self.url = url self.token = token + self.bootstrapToken = bootstrapToken self.password = password self.session = session?.session ?? URLSession(configuration: .default) self.pushHandler = pushHandler @@ -402,22 +412,29 @@ public actor GatewayChannelActor { (includeDeviceIdentity && identity != nil) ? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token : nil + let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let explicitBootstrapToken = + self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let shouldUseDeviceRetryToken = includeDeviceIdentity && self.pendingDeviceTokenRetry && - storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint() + storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() if shouldUseDeviceRetryToken { self.pendingDeviceTokenRetry = false } // Keep shared credentials explicit when provided. Device token retry is attached // only on a bounded second attempt after token mismatch. - let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil) + let authToken = explicitToken ?? (includeDeviceIdentity ? storedToken : nil) + let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil let authSource: GatewayAuthSource - if authDeviceToken != nil || (self.token == nil && storedToken != nil) { + if authDeviceToken != nil || (explicitToken == nil && storedToken != nil) { authSource = .deviceToken } else if authToken != nil { authSource = .sharedToken - } else if self.password != nil { + } else if authBootstrapToken != nil { + authSource = .bootstrapToken + } else if explicitPassword != nil { authSource = .password } else { authSource = .none @@ -430,7 +447,9 @@ public actor GatewayChannelActor { auth["deviceToken"] = ProtoAnyCodable(authDeviceToken) } params["auth"] = ProtoAnyCodable(auth) - } else if let password = self.password { + } else if let authBootstrapToken { + params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)]) + } else if let password = explicitPassword { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) @@ -443,7 +462,7 @@ public actor GatewayChannelActor { role: role, scopes: scopes, signedAtMs: signedAtMs, - token: authToken, + token: authToken ?? authBootstrapToken, nonce: connectNonce, platform: platform, deviceFamily: InstanceIdentity.deviceFamily) @@ -472,7 +491,7 @@ public actor GatewayChannelActor { } catch { let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken( error: error, - explicitGatewayToken: self.token, + explicitGatewayToken: explicitToken, storedToken: storedToken, attemptedDeviceTokenRetry: authDeviceToken != nil) if shouldRetryWithDeviceToken { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift index 3b1d97059..7ef7f4664 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift @@ -5,6 +5,7 @@ public enum GatewayConnectAuthDetailCode: String, Sendable { case authRequired = "AUTH_REQUIRED" case authUnauthorized = "AUTH_UNAUTHORIZED" case authTokenMismatch = "AUTH_TOKEN_MISMATCH" + case authBootstrapTokenInvalid = "AUTH_BOOTSTRAP_TOKEN_INVALID" case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH" case authTokenMissing = "AUTH_TOKEN_MISSING" case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED" @@ -92,6 +93,7 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable { public var isNonRecoverable: Bool { switch self.detail { case .authTokenMissing, + .authBootstrapTokenInvalid, .authTokenNotConfigured, .authPasswordMissing, .authPasswordMismatch, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 378ad10e3..945e482bb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -64,6 +64,7 @@ public actor GatewayNodeSession { private var channel: GatewayChannelActor? private var activeURL: URL? private var activeToken: String? + private var activeBootstrapToken: String? private var activePassword: String? private var activeConnectOptionsKey: String? private var connectOptions: GatewayConnectOptions? @@ -194,6 +195,7 @@ public actor GatewayNodeSession { public func connect( url: URL, token: String?, + bootstrapToken: String?, password: String?, connectOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?, @@ -204,6 +206,7 @@ public actor GatewayNodeSession { let nextOptionsKey = self.connectOptionsKey(connectOptions) let shouldReconnect = self.activeURL != url || self.activeToken != token || + self.activeBootstrapToken != bootstrapToken || self.activePassword != password || self.activeConnectOptionsKey != nextOptionsKey || self.channel == nil @@ -221,6 +224,7 @@ public actor GatewayNodeSession { let channel = GatewayChannelActor( url: url, token: token, + bootstrapToken: bootstrapToken, password: password, session: sessionBox, pushHandler: { [weak self] push in @@ -233,6 +237,7 @@ public actor GatewayNodeSession { self.channel = channel self.activeURL = url self.activeToken = token + self.activeBootstrapToken = bootstrapToken self.activePassword = password self.activeConnectOptionsKey = nextOptionsKey } @@ -257,6 +262,7 @@ public actor GatewayNodeSession { self.channel = nil self.activeURL = nil self.activeToken = nil + self.activeBootstrapToken = nil self.activePassword = nil self.activeConnectOptionsKey = nil self.hasEverConnected = false diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index 8bbf4f8a6..79613b310 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -20,11 +20,17 @@ import Testing string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")! #expect( DeepLinkParser.parse(url) == .gateway( - .init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil))) + .init( + host: "127.0.0.1", + port: 18789, + tls: false, + bootstrapToken: nil, + token: "abc", + password: nil))) } @Test func setupCodeRejectsInsecureNonLoopbackWs() { - let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"# let encoded = Data(payload.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") @@ -34,7 +40,7 @@ import Testing } @Test func setupCodeRejectsInsecurePrefixBypassHost() { - let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"# let encoded = Data(payload.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") @@ -44,7 +50,7 @@ import Testing } @Test func setupCodeAllowsLoopbackWs() { - let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"# let encoded = Data(payload.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") @@ -55,7 +61,8 @@ import Testing host: "127.0.0.1", port: 18789, tls: false, - token: "tok", + bootstrapToken: "tok", + token: nil, password: nil)) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift new file mode 100644 index 000000000..92d3e1292 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -0,0 +1,14 @@ +import OpenClawKit +import Testing + +@Suite struct GatewayErrorsTests { + @Test func bootstrapTokenInvalidIsNonRecoverable() { + let error = GatewayConnectAuthError( + message: "setup code expired", + detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue, + canRetryWithDeviceToken: false) + + #expect(error.isNonRecoverable) + #expect(error.detail == .authBootstrapTokenInvalid) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index a48015e11..183fc385d 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -266,6 +266,7 @@ struct GatewayNodeSessionTests { try await gateway.connect( url: URL(string: "ws://example.invalid")!, token: nil, + bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index d402de166..1ba3c6c92 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -72,7 +72,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire The setup code is a base64-encoded JSON payload that contains: - `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`) -- `token`: a short-lived pairing token +- `bootstrapToken`: a short-lived single-device bootstrap token used for the initial pairing handshake Treat the setup code like a password while it is valid. diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 2fc070ca1..1575b16d0 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -17,7 +17,7 @@ openclaw qr openclaw qr --setup-code-only openclaw qr --json openclaw qr --remote -openclaw qr --url wss://gateway.example/ws --token '' +openclaw qr --url wss://gateway.example/ws ``` ## Options @@ -25,8 +25,8 @@ openclaw qr --url wss://gateway.example/ws --token '' - `--remote`: use `gateway.remote.url` plus remote token/password from config - `--url `: override gateway URL used in payload - `--public-url `: override public URL used in payload -- `--token `: override gateway token for payload -- `--password `: override gateway password for payload +- `--token `: override which gateway token the bootstrap flow authenticates against +- `--password `: override which gateway password the bootstrap flow authenticates against - `--setup-code-only`: print only setup code - `--no-ascii`: skip ASCII QR rendering - `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`) @@ -34,6 +34,7 @@ openclaw qr --url wss://gateway.example/ws --token '' ## Notes - `--token` and `--password` are mutually exclusive. +- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. - With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast. - Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed: - `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins). diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7590703a3..62977a66e 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -2,6 +2,7 @@ import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair"; import { approveDevicePairing, + issueDeviceBootstrapToken, listDevicePairing, resolveGatewayBindUrl, runPluginCommandWithTimeout, @@ -31,8 +32,7 @@ type DevicePairPluginConfig = { type SetupPayload = { url: string; - token?: string; - password?: string; + bootstrapToken: string; }; type ResolveUrlResult = { @@ -405,8 +405,14 @@ export default function register(api: OpenClawPluginApi) { const payload: SetupPayload = { url: urlResult.url, - token: auth.token, - password: auth.password, + bootstrapToken: ( + await issueDeviceBootstrapToken({ + channel: ctx.channel, + senderId: ctx.senderId ?? ctx.from ?? ctx.to, + accountId: ctx.accountId, + threadId: ctx.messageThreadId != null ? String(ctx.messageThreadId) : undefined, + }) + ).token, }; if (action === "qr") { diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 551c17355..d77cd1406 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -27,6 +27,12 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWi vi.mock("./command-secret-gateway.js", () => ({ resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, })); +vi.mock("../infra/device-bootstrap.js", () => ({ + issueDeviceBootstrapToken: vi.fn(async () => ({ + token: "bootstrap-123", + expiresAtMs: 123, + })), +})); vi.mock("qrcode-terminal", () => ({ default: { generate: mocks.qrGenerate, @@ -156,7 +162,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - token: "tok", + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(qrGenerate).not.toHaveBeenCalled(); @@ -194,7 +200,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - token: "override-token", + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); }); @@ -210,7 +216,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - token: "override-token", + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); }); @@ -227,7 +233,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "local-password-secret", // pragma: allowlist secret + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -245,7 +251,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "password-from-env", // pragma: allowlist secret + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -264,7 +270,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - token: "token-123", + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -282,7 +288,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "inferred-password", // pragma: allowlist secret + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -332,7 +338,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", - token: "remote-tok", + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( @@ -375,7 +381,7 @@ describe("registerQrCli", () => { ).toBe(true); const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", - token: "remote-tok", + bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); }); diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index 5db9bb43d..7a6dedef0 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -66,12 +66,22 @@ function createGatewayTokenRefFixture() { }; } -function decodeSetupCode(setupCode: string): { url?: string; token?: string; password?: string } { +function decodeSetupCode(setupCode: string): { + url?: string; + bootstrapToken?: string; + token?: string; + password?: string; +} { const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/"); const padLength = (4 - (padded.length % 4)) % 4; const normalized = padded + "=".repeat(padLength); const json = Buffer.from(normalized, "base64").toString("utf8"); - return JSON.parse(json) as { url?: string; token?: string; password?: string }; + return JSON.parse(json) as { + url?: string; + bootstrapToken?: string; + token?: string; + password?: string; + }; } async function runCli(args: string[]): Promise { @@ -126,7 +136,8 @@ describe("cli integration: qr + dashboard token SecretRef", () => { expect(setupCode).toBeTruthy(); const payload = decodeSetupCode(setupCode ?? ""); expect(payload.url).toBe("ws://gateway.local:18789"); - expect(payload.token).toBe("shared-token-123"); + expect(payload.bootstrapToken).toBeTruthy(); + expect(payload.token).toBeUndefined(); expect(runtimeErrors).toEqual([]); runtimeLogs.length = 0; diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 8220cccb0..dbfac4c86 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -40,7 +40,14 @@ export type ResolvedGatewayAuth = { export type GatewayAuthResult = { ok: boolean; - method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy"; + method?: + | "none" + | "token" + | "password" + | "tailscale" + | "device-token" + | "bootstrap-token" + | "trusted-proxy"; user?: string; reason?: string; /** Present when the request was blocked by the rate limiter. */ diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index eb081520a..04217b96a 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -335,6 +335,7 @@ describe("GatewayClient connect auth payload", () => { params?: { auth?: { token?: string; + bootstrapToken?: string; deviceToken?: string; password?: string; }; @@ -410,6 +411,26 @@ describe("GatewayClient connect auth payload", () => { client.stop(); }); + it("uses bootstrap token when no shared or device token is available", () => { + loadDeviceAuthTokenMock.mockReturnValue(undefined); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + bootstrapToken: "bootstrap-token", + }); + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); + emitConnectChallenge(ws); + + expect(connectFrameFrom(ws)).toMatchObject({ + bootstrapToken: "bootstrap-token", + }); + expect(connectFrameFrom(ws).token).toBeUndefined(); + expect(connectFrameFrom(ws).deviceToken).toBeUndefined(); + client.stop(); + }); + it("prefers explicit deviceToken over stored device token", () => { loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); const client = new GatewayClient({ diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 489347e54..eb6461e54 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -69,6 +69,7 @@ export type GatewayClientOptions = { connectDelayMs?: number; tickWatchMinIntervalMs?: number; token?: string; + bootstrapToken?: string; deviceToken?: string; password?: string; instanceId?: string; @@ -281,6 +282,7 @@ export class GatewayClient { } const role = this.opts.role ?? "operator"; const explicitGatewayToken = this.opts.token?.trim() || undefined; + const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined; const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined; const storedToken = this.opts.deviceIdentity ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token @@ -294,21 +296,27 @@ export class GatewayClient { if (shouldUseDeviceRetryToken) { this.pendingDeviceTokenRetry = false; } - // Keep shared gateway credentials explicit. Persisted per-device tokens only - // participate when no explicit shared token/password is provided. + // Shared gateway credentials stay explicit. Bootstrap tokens are different: + // once a role-scoped device token exists, it should take precedence so the + // temporary bootstrap secret falls out of active use. const resolvedDeviceToken = explicitDeviceToken ?? - (shouldUseDeviceRetryToken || !(explicitGatewayToken || this.opts.password?.trim()) + (shouldUseDeviceRetryToken || + (!(explicitGatewayToken || this.opts.password?.trim()) && + (!explicitBootstrapToken || Boolean(storedToken))) ? (storedToken ?? undefined) : undefined); // Legacy compatibility: keep `auth.token` populated for device-token auth when // no explicit shared token is present. const authToken = explicitGatewayToken ?? resolvedDeviceToken; + const authBootstrapToken = + !explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined; const authPassword = this.opts.password?.trim() || undefined; const auth = - authToken || authPassword || resolvedDeviceToken + authToken || authBootstrapToken || authPassword || resolvedDeviceToken ? { token: authToken, + bootstrapToken: authBootstrapToken, deviceToken: resolvedDeviceToken, password: authPassword, } @@ -327,7 +335,7 @@ export class GatewayClient { role, scopes, signedAtMs, - token: authToken ?? null, + token: authToken ?? authBootstrapToken ?? null, nonce, platform, deviceFamily: this.opts.deviceFamily, @@ -420,6 +428,7 @@ export class GatewayClient { } if ( detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || + detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID || detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 298241c62..472bb0573 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -7,6 +7,7 @@ export const ConnectErrorDetailCodes = { AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", // pragma: allowlist secret AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", // pragma: allowlist secret AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED", // pragma: allowlist secret + AUTH_BOOTSTRAP_TOKEN_INVALID: "AUTH_BOOTSTRAP_TOKEN_INVALID", AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH", AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED", AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING", @@ -64,6 +65,8 @@ export function resolveAuthConnectErrorDetailCode( return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH; case "password_missing_config": return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED; + case "bootstrap_token_invalid": + return ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID; case "tailscale_user_missing": return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING; case "tailscale_proxy_missing": diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index d01aa83cc..d5ebadd2d 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -56,6 +56,7 @@ export const ConnectParamsSchema = Type.Object( Type.Object( { token: Type.Optional(Type.String()), + bootstrapToken: Type.Optional(Type.String()), deviceToken: Type.Optional(Type.String()), password: Type.Optional(Type.String()), }, diff --git a/src/gateway/reconnect-gating.test.ts b/src/gateway/reconnect-gating.test.ts index d073cc59c..aeb60f2e5 100644 --- a/src/gateway/reconnect-gating.test.ts +++ b/src/gateway/reconnect-gating.test.ts @@ -21,6 +21,12 @@ describe("isNonRecoverableAuthError", () => { ); }); + it("blocks reconnect for AUTH_BOOTSTRAP_TOKEN_INVALID", () => { + expect( + isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID)), + ).toBe(true); + }); + it("blocks reconnect for AUTH_PASSWORD_MISSING", () => { expect( isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)), diff --git a/src/gateway/server/ws-connection/auth-context.test.ts b/src/gateway/server/ws-connection/auth-context.test.ts index 130b05664..49c345f1e 100644 --- a/src/gateway/server/ws-connection/auth-context.test.ts +++ b/src/gateway/server/ws-connection/auth-context.test.ts @@ -3,6 +3,9 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js"; import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js"; type VerifyDeviceTokenFn = Parameters[0]["verifyDeviceToken"]; +type VerifyBootstrapTokenFn = Parameters< + typeof resolveConnectAuthDecision +>[0]["verifyBootstrapToken"]; function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): { limiter: AuthRateLimiter; @@ -38,6 +41,7 @@ function createBaseState(overrides?: Partial): ConnectAuthStat async function resolveDeviceTokenDecision(params: { verifyDeviceToken: VerifyDeviceTokenFn; + verifyBootstrapToken?: VerifyBootstrapTokenFn; stateOverrides?: Partial; rateLimiter?: AuthRateLimiter; clientIp?: string; @@ -46,8 +50,12 @@ async function resolveDeviceTokenDecision(params: { state: createBaseState(params.stateOverrides), hasDeviceIdentity: true, deviceId: "dev-1", + publicKey: "pub-1", role: "operator", scopes: ["operator.read"], + verifyBootstrapToken: + params.verifyBootstrapToken ?? + (async () => ({ ok: false, reason: "bootstrap_token_invalid" })), verifyDeviceToken: params.verifyDeviceToken, ...(params.rateLimiter ? { rateLimiter: params.rateLimiter } : {}), ...(params.clientIp ? { clientIp: params.clientIp } : {}), @@ -57,16 +65,23 @@ async function resolveDeviceTokenDecision(params: { describe("resolveConnectAuthDecision", () => { it("keeps shared-secret mismatch when fallback device-token check fails", async () => { const verifyDeviceToken = vi.fn(async () => ({ ok: false })); + const verifyBootstrapToken = vi.fn(async () => ({ + ok: false, + reason: "bootstrap_token_invalid", + })); const decision = await resolveConnectAuthDecision({ state: createBaseState(), hasDeviceIdentity: true, deviceId: "dev-1", + publicKey: "pub-1", role: "operator", scopes: ["operator.read"], + verifyBootstrapToken, verifyDeviceToken, }); expect(decision.authOk).toBe(false); expect(decision.authResult.reason).toBe("token_mismatch"); + expect(verifyBootstrapToken).not.toHaveBeenCalled(); expect(verifyDeviceToken).toHaveBeenCalledOnce(); }); @@ -78,8 +93,10 @@ describe("resolveConnectAuthDecision", () => { }), hasDeviceIdentity: true, deviceId: "dev-1", + publicKey: "pub-1", role: "operator", scopes: ["operator.read"], + verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }), verifyDeviceToken, }); expect(decision.authOk).toBe(false); @@ -100,6 +117,44 @@ describe("resolveConnectAuthDecision", () => { expect(rateLimiter.reset).toHaveBeenCalledOnce(); }); + it("accepts valid bootstrap tokens before device-token fallback", async () => { + const verifyBootstrapToken = vi.fn(async () => ({ ok: true })); + const verifyDeviceToken = vi.fn(async () => ({ ok: true })); + const decision = await resolveDeviceTokenDecision({ + verifyBootstrapToken, + verifyDeviceToken, + stateOverrides: { + bootstrapTokenCandidate: "bootstrap-token", + deviceTokenCandidate: "device-token", + }, + }); + expect(decision.authOk).toBe(true); + expect(decision.authMethod).toBe("bootstrap-token"); + expect(verifyBootstrapToken).toHaveBeenCalledOnce(); + expect(verifyDeviceToken).not.toHaveBeenCalled(); + }); + + it("reports invalid bootstrap tokens when no device token fallback is available", async () => { + const verifyBootstrapToken = vi.fn(async () => ({ + ok: false, + reason: "bootstrap_token_invalid", + })); + const verifyDeviceToken = vi.fn(async () => ({ ok: true })); + const decision = await resolveDeviceTokenDecision({ + verifyBootstrapToken, + verifyDeviceToken, + stateOverrides: { + bootstrapTokenCandidate: "bootstrap-token", + deviceTokenCandidate: undefined, + deviceTokenCandidateSource: undefined, + }, + }); + expect(decision.authOk).toBe(false); + expect(decision.authResult.reason).toBe("bootstrap_token_invalid"); + expect(verifyBootstrapToken).toHaveBeenCalledOnce(); + expect(verifyDeviceToken).not.toHaveBeenCalled(); + }); + it("returns rate-limited auth result without verifying device token", async () => { const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 }); const verifyDeviceToken = vi.fn(async () => ({ ok: true })); @@ -123,8 +178,10 @@ describe("resolveConnectAuthDecision", () => { }), hasDeviceIdentity: true, deviceId: "dev-1", + publicKey: "pub-1", role: "operator", scopes: [], + verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }), verifyDeviceToken, }); expect(decision.authOk).toBe(true); diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index cb7977722..bf5d3a25f 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -14,6 +14,7 @@ import { type HandshakeConnectAuth = { token?: string; + bootstrapToken?: string; deviceToken?: string; password?: string; }; @@ -26,11 +27,13 @@ export type ConnectAuthState = { authMethod: GatewayAuthResult["method"]; sharedAuthOk: boolean; sharedAuthProvided: boolean; + bootstrapTokenCandidate?: string; deviceTokenCandidate?: string; deviceTokenCandidateSource?: DeviceTokenCandidateSource; }; type VerifyDeviceTokenResult = { ok: boolean }; +type VerifyBootstrapTokenResult = { ok: boolean; reason?: string }; export type ConnectAuthDecision = { authResult: GatewayAuthResult; @@ -72,6 +75,12 @@ function resolveDeviceTokenCandidate(connectAuth: HandshakeConnectAuth | null | return { token: fallbackToken, source: "shared-token-fallback" }; } +function resolveBootstrapTokenCandidate( + connectAuth: HandshakeConnectAuth | null | undefined, +): string | undefined { + return trimToUndefined(connectAuth?.bootstrapToken); +} + export async function resolveConnectAuthState(params: { resolvedAuth: ResolvedGatewayAuth; connectAuth: HandshakeConnectAuth | null | undefined; @@ -84,6 +93,9 @@ export async function resolveConnectAuthState(params: { }): Promise { const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth); const sharedAuthProvided = Boolean(sharedConnectAuth); + const bootstrapTokenCandidate = params.hasDeviceIdentity + ? resolveBootstrapTokenCandidate(params.connectAuth) + : undefined; const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } = params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {}; const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate); @@ -148,6 +160,7 @@ export async function resolveConnectAuthState(params: { authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"), sharedAuthOk, sharedAuthProvided, + bootstrapTokenCandidate, deviceTokenCandidate, deviceTokenCandidateSource, }; @@ -157,10 +170,18 @@ export async function resolveConnectAuthDecision(params: { state: ConnectAuthState; hasDeviceIdentity: boolean; deviceId?: string; + publicKey?: string; role: string; scopes: string[]; rateLimiter?: AuthRateLimiter; clientIp?: string; + verifyBootstrapToken: (params: { + deviceId: string; + publicKey: string; + token: string; + role: string; + scopes: string[]; + }) => Promise; verifyDeviceToken: (params: { deviceId: string; token: string; @@ -172,6 +193,29 @@ export async function resolveConnectAuthDecision(params: { let authOk = params.state.authOk; let authMethod = params.state.authMethod; + const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate; + if ( + params.hasDeviceIdentity && + params.deviceId && + params.publicKey && + !authOk && + bootstrapTokenCandidate + ) { + const tokenCheck = await params.verifyBootstrapToken({ + deviceId: params.deviceId, + publicKey: params.publicKey, + token: bootstrapTokenCandidate, + role: params.role, + scopes: params.scopes, + }); + if (tokenCheck.ok) { + authOk = true; + authMethod = "bootstrap-token"; + } else { + authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" }; + } + } + const deviceTokenCandidate = params.state.deviceTokenCandidate; if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) { return { authResult, authOk, authMethod }; diff --git a/src/gateway/server/ws-connection/auth-messages.ts b/src/gateway/server/ws-connection/auth-messages.ts index bf7cc32e1..7da8ef123 100644 --- a/src/gateway/server/ws-connection/auth-messages.ts +++ b/src/gateway/server/ws-connection/auth-messages.ts @@ -2,7 +2,7 @@ import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-chan import type { ResolvedGatewayAuth } from "../../auth.js"; import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; -export type AuthProvidedKind = "token" | "device-token" | "password" | "none"; +export type AuthProvidedKind = "token" | "bootstrap-token" | "device-token" | "password" | "none"; export function formatGatewayAuthFailureMessage(params: { authMode: ResolvedGatewayAuth["mode"]; @@ -38,6 +38,8 @@ export function formatGatewayAuthFailureMessage(params: { return `unauthorized: gateway password mismatch (${passwordHint})`; case "password_missing_config": return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)"; + case "bootstrap_token_invalid": + return "unauthorized: bootstrap token invalid or expired (scan a fresh setup code)"; case "tailscale_user_missing": return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)"; case "tailscale_proxy_missing": @@ -60,6 +62,9 @@ export function formatGatewayAuthFailureMessage(params: { if (authMode === "token" && authProvided === "device-token") { return "unauthorized: device token rejected (pair/repair this device, or provide gateway token)"; } + if (authProvided === "bootstrap-token") { + return "unauthorized: bootstrap token invalid or expired (scan a fresh setup code)"; + } if (authMode === "password" && authProvided === "none") { return `unauthorized: gateway password missing (${passwordHint})`; } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 0c71ee9df..d0b6e5790 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -2,6 +2,7 @@ import type { IncomingMessage } from "node:http"; import os from "node:os"; import type { WebSocket } from "ws"; import { loadConfig } from "../../../config/config.js"; +import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js"; import { deriveDeviceIdFromPublicKey, normalizeDevicePublicKeyBase64Url, @@ -186,7 +187,11 @@ function resolveDeviceSignaturePayloadVersion(params: { role: params.role, scopes: params.scopes, signedAtMs: params.signedAtMs, - token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null, + token: + params.connectParams.auth?.token ?? + params.connectParams.auth?.deviceToken ?? + params.connectParams.auth?.bootstrapToken ?? + null, nonce: params.nonce, platform: params.connectParams.client.platform, deviceFamily: params.connectParams.client.deviceFamily, @@ -202,7 +207,11 @@ function resolveDeviceSignaturePayloadVersion(params: { role: params.role, scopes: params.scopes, signedAtMs: params.signedAtMs, - token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null, + token: + params.connectParams.auth?.token ?? + params.connectParams.auth?.deviceToken ?? + params.connectParams.auth?.bootstrapToken ?? + null, nonce: params.nonce, }); if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) { @@ -566,6 +575,7 @@ export function attachGatewayWsMessageHandler(params: { authOk, authMethod, sharedAuthOk, + bootstrapTokenCandidate, deviceTokenCandidate, deviceTokenCandidateSource, } = await resolveConnectAuthState({ @@ -610,9 +620,11 @@ export function attachGatewayWsMessageHandler(params: { ? "password" : connectParams.auth?.token ? "token" - : connectParams.auth?.deviceToken - ? "device-token" - : "none", + : connectParams.auth?.bootstrapToken + ? "bootstrap-token" + : connectParams.auth?.deviceToken + ? "device-token" + : "none", authReason: failedAuth.reason, allowTailscale: resolvedAuth.allowTailscale, }); @@ -623,9 +635,11 @@ export function attachGatewayWsMessageHandler(params: { ? "password" : connectParams.auth?.token ? "token" - : connectParams.auth?.deviceToken - ? "device-token" - : "none"; + : connectParams.auth?.bootstrapToken + ? "bootstrap-token" + : connectParams.auth?.deviceToken + ? "device-token" + : "none"; const authMessage = formatGatewayAuthFailureMessage({ authMode: resolvedAuth.mode, authProvided, @@ -774,15 +788,25 @@ export function attachGatewayWsMessageHandler(params: { authMethod, sharedAuthOk, sharedAuthProvided: hasSharedAuth, + bootstrapTokenCandidate, deviceTokenCandidate, deviceTokenCandidateSource, }, hasDeviceIdentity: Boolean(device), deviceId: device?.id, + publicKey: device?.publicKey, role, scopes, rateLimiter: authRateLimiter, clientIp: browserRateLimitClientIp, + verifyBootstrapToken: async ({ deviceId, publicKey, token, role, scopes }) => + await verifyDeviceBootstrapToken({ + deviceId, + publicKey, + token, + role, + scopes, + }), verifyDeviceToken, })); if (!authOk) { diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts new file mode 100644 index 000000000..b5f64790d --- /dev/null +++ b/src/infra/device-bootstrap.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEVICE_BOOTSTRAP_TOKEN_TTL_MS, + issueDeviceBootstrapToken, + verifyDeviceBootstrapToken, +} from "./device-bootstrap.js"; + +const tempRoots: string[] = []; + +async function createBaseDir(): Promise { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-bootstrap-")); + tempRoots.push(baseDir); + return baseDir; +} + +afterEach(async () => { + vi.useRealTimers(); + await Promise.all( + tempRoots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })), + ); +}); + +describe("device bootstrap tokens", () => { + it("binds the first successful verification to a device identity", async () => { + const baseDir = await createBaseDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "pub-1", + role: "node", + scopes: ["node.invoke"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "pub-1", + role: "operator", + scopes: ["operator.read"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + }); + + it("rejects reuse from a different device after binding", async () => { + const baseDir = await createBaseDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "pub-1", + role: "node", + scopes: ["node.invoke"], + baseDir, + }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-2", + publicKey: "pub-2", + role: "node", + scopes: ["node.invoke"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + }); + + it("expires bootstrap tokens after the ttl window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-12T10:00:00Z")); + const baseDir = await createBaseDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + vi.setSystemTime(new Date(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS + 1)); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "pub-1", + role: "node", + scopes: ["node.invoke"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + }); +}); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts new file mode 100644 index 000000000..438500f4f --- /dev/null +++ b/src/infra/device-bootstrap.ts @@ -0,0 +1,152 @@ +import path from "node:path"; +import { resolvePairingPaths } from "./pairing-files.js"; +import { + createAsyncLock, + pruneExpiredPending, + readJsonFile, + writeJsonAtomic, +} from "./pairing-files.js"; +import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; + +export const DEVICE_BOOTSTRAP_TOKEN_TTL_MS = 10 * 60 * 1000; + +export type DeviceBootstrapTokenRecord = { + token: string; + ts: number; + deviceId?: string; + publicKey?: string; + roles?: string[]; + scopes?: string[]; + channel?: string; + senderId?: string; + accountId?: string; + threadId?: string; + issuedAtMs: number; + lastUsedAtMs?: number; +}; + +type DeviceBootstrapStateFile = Record; + +const withLock = createAsyncLock(); + +function normalizeOptionalString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function mergeRoles(existing: string[] | undefined, role: string): string[] { + const out = new Set(existing ?? []); + const trimmed = role.trim(); + if (trimmed) { + out.add(trimmed); + } + return [...out]; +} + +function mergeScopes( + existing: string[] | undefined, + scopes: readonly string[], +): string[] | undefined { + const out = new Set(existing ?? []); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) { + out.add(trimmed); + } + } + return out.size > 0 ? [...out] : undefined; +} + +function resolveBootstrapPath(baseDir?: string): string { + return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json"); +} + +async function loadState(baseDir?: string): Promise { + const bootstrapPath = resolveBootstrapPath(baseDir); + const state = (await readJsonFile(bootstrapPath)) ?? {}; + for (const entry of Object.values(state)) { + if (typeof entry.ts !== "number") { + entry.ts = entry.issuedAtMs; + } + } + pruneExpiredPending(state, Date.now(), DEVICE_BOOTSTRAP_TOKEN_TTL_MS); + return state; +} + +async function persistState(state: DeviceBootstrapStateFile, baseDir?: string): Promise { + const bootstrapPath = resolveBootstrapPath(baseDir); + await writeJsonAtomic(bootstrapPath, state); +} + +export async function issueDeviceBootstrapToken( + params: { + channel?: string; + senderId?: string; + accountId?: string; + threadId?: string; + baseDir?: string; + } = {}, +): Promise<{ token: string; expiresAtMs: number }> { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const token = generatePairingToken(); + const issuedAtMs = Date.now(); + state[token] = { + token, + ts: issuedAtMs, + channel: normalizeOptionalString(params.channel), + senderId: normalizeOptionalString(params.senderId), + accountId: normalizeOptionalString(params.accountId), + threadId: normalizeOptionalString(params.threadId), + issuedAtMs, + }; + await persistState(state, params.baseDir); + return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS }; + }); +} + +export async function verifyDeviceBootstrapToken(params: { + token: string; + deviceId: string; + publicKey: string; + role: string; + scopes: readonly string[]; + baseDir?: string; +}): Promise<{ ok: true } | { ok: false; reason: string }> { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const providedToken = params.token.trim(); + if (!providedToken) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + const entry = Object.values(state).find((candidate) => + verifyPairingToken(providedToken, candidate.token), + ); + if (!entry) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + + const deviceId = params.deviceId.trim(); + const publicKey = params.publicKey.trim(); + const role = params.role.trim(); + if (!deviceId || !publicKey || !role) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + + if (entry.deviceId && entry.deviceId !== deviceId) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + if (entry.publicKey && entry.publicKey !== publicKey) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + + entry.deviceId = deviceId; + entry.publicKey = publicKey; + entry.roles = mergeRoles(entry.roles, role); + entry.scopes = mergeScopes(entry.scopes, params.scopes); + entry.lastUsedAtMs = Date.now(); + state[entry.token] = entry; + await persistState(state, params.baseDir); + return { ok: true }; + }); +} diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index c670d8deb..6a6885828 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -2,6 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SecretInput } from "../config/types.secrets.js"; import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.js"; +vi.mock("../infra/device-bootstrap.js", () => ({ + issueDeviceBootstrapToken: vi.fn(async () => ({ + token: "bootstrap-123", + expiresAtMs: 123, + })), +})); + describe("pairing setup code", () => { function createTailnetDnsRunner() { return vi.fn(async () => ({ @@ -25,10 +32,12 @@ describe("pairing setup code", () => { it("encodes payload as base64url JSON", () => { const code = encodePairingSetupCode({ url: "wss://gateway.example.com:443", - token: "abc", + bootstrapToken: "abc", }); - expect(code).toBe("eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsInRva2VuIjoiYWJjIn0"); + expect(code).toBe( + "eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsImJvb3RzdHJhcFRva2VuIjoiYWJjIn0", + ); }); it("resolves custom bind + token auth", async () => { @@ -45,8 +54,7 @@ describe("pairing setup code", () => { ok: true, payload: { url: "ws://gateway.local:19001", - token: "tok_123", - password: undefined, + bootstrapToken: "bootstrap-123", }, authLabel: "token", urlSource: "gateway.bind=custom", @@ -81,7 +89,7 @@ describe("pairing setup code", () => { if (!resolved.ok) { throw new Error("expected setup resolution to succeed"); } - expect(resolved.payload.password).toBe("resolved-password"); + expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); expect(resolved.authLabel).toBe("password"); }); @@ -113,7 +121,7 @@ describe("pairing setup code", () => { if (!resolved.ok) { throw new Error("expected setup resolution to succeed"); } - expect(resolved.payload.password).toBe("password-from-env"); + expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); expect(resolved.authLabel).toBe("password"); }); @@ -145,7 +153,7 @@ describe("pairing setup code", () => { throw new Error("expected setup resolution to succeed"); } expect(resolved.authLabel).toBe("token"); - expect(resolved.payload.token).toBe("tok_123"); + expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); }); it("resolves gateway.auth.token SecretRef for pairing payload", async () => { @@ -177,7 +185,7 @@ describe("pairing setup code", () => { throw new Error("expected setup resolution to succeed"); } expect(resolved.authLabel).toBe("token"); - expect(resolved.payload.token).toBe("resolved-token"); + expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); }); it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => { @@ -239,7 +247,7 @@ describe("pairing setup code", () => { throw new Error("expected setup resolution to succeed"); } expect(resolved.authLabel).toBe("password"); - expect(resolved.payload.password).toBe("password-from-env"); + expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); }); it("does not treat env-template token as plaintext in inferred mode", async () => { @@ -250,8 +258,7 @@ describe("pairing setup code", () => { throw new Error("expected setup resolution to succeed"); } expect(resolved.authLabel).toBe("password"); - expect(resolved.payload.token).toBeUndefined(); - expect(resolved.payload.password).toBe("password-from-env"); + expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); }); it("requires explicit auth mode when token and password are both configured", async () => { @@ -329,7 +336,7 @@ describe("pairing setup code", () => { if (!resolved.ok) { throw new Error("expected setup resolution to succeed"); } - expect(resolved.payload.token).toBe("new-token"); + expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); }); it("errors when gateway is loopback only", async () => { @@ -366,8 +373,7 @@ describe("pairing setup code", () => { ok: true, payload: { url: "wss://mb-server.tailnet.ts.net", - token: undefined, - password: "secret", + bootstrapToken: "bootstrap-123", }, authLabel: "password", urlSource: "gateway.tailscale.mode=serve", @@ -395,8 +401,7 @@ describe("pairing setup code", () => { ok: true, payload: { url: "wss://remote.example.com:444", - token: "tok_123", - password: undefined, + bootstrapToken: "bootstrap-123", }, authLabel: "token", urlSource: "gateway.remote.url", diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 2e4246b19..de8f3c651 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -8,14 +8,14 @@ import { } from "../config/types.secrets.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js"; +import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js"; import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type PairingSetupPayload = { url: string; - token?: string; - password?: string; + bootstrapToken: string; }; export type PairingSetupCommandResult = { @@ -34,6 +34,7 @@ export type ResolvePairingSetupOptions = { publicUrl?: string; preferRemoteUrl?: boolean; forceSecure?: boolean; + pairingBaseDir?: string; runCommandWithTimeout?: PairingSetupCommandRunner; networkInterfaces?: () => ReturnType; }; @@ -388,8 +389,11 @@ export async function resolvePairingSetupFromConfig( ok: true, payload: { url: urlResult.url, - token: auth.token, - password: auth.password, + bootstrapToken: ( + await issueDeviceBootstrapToken({ + baseDir: options.pairingBaseDir, + }) + ).token, }, authLabel: auth.label, urlSource: urlResult.source ?? "unknown", diff --git a/src/plugin-sdk/device-pair.ts b/src/plugin-sdk/device-pair.ts index a2df85772..5828ad053 100644 --- a/src/plugin-sdk/device-pair.ts +++ b/src/plugin-sdk/device-pair.ts @@ -2,6 +2,7 @@ // Keep this list additive and scoped to symbols used under extensions/device-pair. export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index c0d9ef712..53cfa086f 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -69,6 +69,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): const code = resolveGatewayErrorDetailCode(error); return ( code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || + code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID || code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||