Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Steinberger
0aa1c69a79 fix: switch pairing setup codes to bootstrap tokens 2026-03-12 19:04:04 -04:00
53 changed files with 1035 additions and 106 deletions

View File

@ -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. - 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. - 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. - 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. - 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. - 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.

View File

@ -116,6 +116,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setGatewayToken(value) runtime.setGatewayToken(value)
} }
fun setGatewayBootstrapToken(value: String) {
runtime.setGatewayBootstrapToken(value)
}
fun setGatewayPassword(value: String) { fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value) runtime.setGatewayPassword(value)
} }

View File

@ -503,6 +503,7 @@ class NodeRuntime(context: Context) {
val gatewayToken: StateFlow<String> = prefs.gatewayToken val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
fun setGatewayToken(value: String) = prefs.setGatewayToken(value) fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
@ -698,10 +699,25 @@ class NodeRuntime(context: Context) {
operatorStatusText = "Connecting…" operatorStatusText = "Connecting…"
updateStatus() updateStatus()
val token = prefs.loadGatewayToken() val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword() val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint) val tls = connectionManager.resolveTlsParams(endpoint)
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) operatorSession.connect(
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildNodeConnectOptions(),
tls,
)
operatorSession.reconnect() operatorSession.reconnect()
nodeSession.reconnect() nodeSession.reconnect()
} }
@ -726,9 +742,24 @@ class NodeRuntime(context: Context) {
nodeStatusText = "Connecting…" nodeStatusText = "Connecting…"
updateStatus() updateStatus()
val token = prefs.loadGatewayToken() val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword() val password = prefs.loadGatewayPassword()
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) operatorSession.connect(
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildNodeConnectOptions(),
tls,
)
} }
fun acceptGatewayTrustPrompt() { fun acceptGatewayTrustPrompt() {

View File

@ -15,7 +15,10 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID import java.util.UUID
class SecurePrefs(context: Context) { class SecurePrefs(
context: Context,
private val securePrefsOverride: SharedPreferences? = null,
) {
companion object { companion object {
val defaultWakeWords: List<String> = listOf("openclaw", "claude") val defaultWakeWords: List<String> = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName" private const val displayNameKey = "node.displayName"
@ -35,7 +38,7 @@ class SecurePrefs(context: Context) {
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .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()) private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId val instanceId: StateFlow<String> = _instanceId
@ -76,6 +79,9 @@ class SecurePrefs(context: Context) {
private val _gatewayToken = MutableStateFlow("") private val _gatewayToken = MutableStateFlow("")
val gatewayToken: StateFlow<String> = _gatewayToken val gatewayToken: StateFlow<String> = _gatewayToken
private val _gatewayBootstrapToken = MutableStateFlow("")
val gatewayBootstrapToken: StateFlow<String> = _gatewayBootstrapToken
private val _onboardingCompleted = private val _onboardingCompleted =
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false)) MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
@ -165,6 +171,10 @@ class SecurePrefs(context: Context) {
saveGatewayPassword(value) saveGatewayPassword(value)
} }
fun setGatewayBootstrapToken(value: String) {
saveGatewayBootstrapToken(value)
}
fun setOnboardingCompleted(value: Boolean) { fun setOnboardingCompleted(value: Boolean) {
plainPrefs.edit { putBoolean("onboarding.completed", value) } plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value _onboardingCompleted.value = value
@ -193,6 +203,26 @@ class SecurePrefs(context: Context) {
securePrefs.edit { putString(key, token.trim()) } 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? { fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}" val key = "gateway.password.${_instanceId.value}"
val stored = securePrefs.getString(key, null)?.trim() val stored = securePrefs.getString(key, null)?.trim()

View File

@ -95,6 +95,7 @@ class GatewaySession(
private data class DesiredConnection( private data class DesiredConnection(
val endpoint: GatewayEndpoint, val endpoint: GatewayEndpoint,
val token: String?, val token: String?,
val bootstrapToken: String?,
val password: String?, val password: String?,
val options: GatewayConnectOptions, val options: GatewayConnectOptions,
val tls: GatewayTlsParams?, val tls: GatewayTlsParams?,
@ -107,11 +108,12 @@ class GatewaySession(
fun connect( fun connect(
endpoint: GatewayEndpoint, endpoint: GatewayEndpoint,
token: String?, token: String?,
bootstrapToken: String?,
password: String?, password: String?,
options: GatewayConnectOptions, options: GatewayConnectOptions,
tls: GatewayTlsParams? = null, tls: GatewayTlsParams? = null,
) { ) {
desired = DesiredConnection(endpoint, token, password, options, tls) desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
if (job == null) { if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() } job = scope.launch(Dispatchers.IO) { runLoop() }
} }
@ -219,6 +221,7 @@ class GatewaySession(
private inner class Connection( private inner class Connection(
private val endpoint: GatewayEndpoint, private val endpoint: GatewayEndpoint,
private val token: String?, private val token: String?,
private val bootstrapToken: String?,
private val password: String?, private val password: String?,
private val options: GatewayConnectOptions, private val options: GatewayConnectOptions,
private val tls: GatewayTlsParams?, private val tls: GatewayTlsParams?,
@ -346,9 +349,18 @@ class GatewaySession(
val identity = identityStore.loadOrCreate() val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty() 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. // 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 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) val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) { if (!res.ok) {
val msg = res.error?.message ?: "connect failed" val msg = res.error?.message ?: "connect failed"
@ -381,6 +393,7 @@ class GatewaySession(
identity: DeviceIdentity, identity: DeviceIdentity,
connectNonce: String, connectNonce: String,
authToken: String, authToken: String,
authBootstrapToken: String,
authPassword: String?, authPassword: String?,
): JsonObject { ): JsonObject {
val client = options.client val client = options.client
@ -404,6 +417,10 @@ class GatewaySession(
buildJsonObject { buildJsonObject {
put("token", JsonPrimitive(authToken)) put("token", JsonPrimitive(authToken))
} }
authBootstrapToken.isNotEmpty() ->
buildJsonObject {
put("bootstrapToken", JsonPrimitive(authBootstrapToken))
}
password.isNotEmpty() -> password.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("password", JsonPrimitive(password)) put("password", JsonPrimitive(password))
@ -420,7 +437,12 @@ class GatewaySession(
role = options.role, role = options.role,
scopes = options.scopes, scopes = options.scopes,
signedAtMs = signedAtMs, signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null, token =
when {
authToken.isNotEmpty() -> authToken
authBootstrapToken.isNotEmpty() -> authBootstrapToken
else -> null
},
nonce = connectNonce, nonce = connectNonce,
platform = client.platform, platform = client.platform,
deviceFamily = client.deviceFamily, deviceFamily = client.deviceFamily,
@ -622,7 +644,15 @@ class GatewaySession(
} }
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) { 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 currentConnection = conn
try { try {
conn.connect() conn.connect()

View File

@ -200,8 +200,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
viewModel.setManualHost(config.host) viewModel.setManualHost(config.host)
viewModel.setManualPort(config.port) viewModel.setManualPort(config.port)
viewModel.setManualTls(config.tls) viewModel.setManualTls(config.tls)
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
if (config.token.isNotBlank()) { if (config.token.isNotBlank()) {
viewModel.setGatewayToken(config.token) viewModel.setGatewayToken(config.token)
} else if (config.bootstrapToken.isNotBlank()) {
viewModel.setGatewayToken("")
} }
viewModel.setGatewayPassword(config.password) viewModel.setGatewayPassword(config.password)
viewModel.connectManual() viewModel.connectManual()

View File

@ -1,8 +1,8 @@
package ai.openclaw.app.ui package ai.openclaw.app.ui
import androidx.core.net.toUri
import java.util.Base64 import java.util.Base64
import java.util.Locale import java.util.Locale
import java.net.URI
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@ -18,6 +18,7 @@ internal data class GatewayEndpointConfig(
internal data class GatewaySetupCode( internal data class GatewaySetupCode(
val url: String, val url: String,
val bootstrapToken: String?,
val token: String?, val token: String?,
val password: String?, val password: String?,
) )
@ -26,6 +27,7 @@ internal data class GatewayConnectConfig(
val host: String, val host: String,
val port: Int, val port: Int,
val tls: Boolean, val tls: Boolean,
val bootstrapToken: String,
val token: String, val token: String,
val password: String, val password: String,
) )
@ -44,12 +46,26 @@ internal fun resolveGatewayConnectConfig(
if (useSetupCode) { if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpoint(setup.url) ?: 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( return GatewayConnectConfig(
host = parsed.host, host = parsed.host,
port = parsed.port, port = parsed.port,
tls = parsed.tls, tls = parsed.tls,
token = setup.token ?: fallbackToken.trim(), bootstrapToken = setupBootstrapToken,
password = setup.password ?: fallbackPassword.trim(), token = sharedToken,
password = sharedPassword,
) )
} }
@ -59,6 +75,7 @@ internal fun resolveGatewayConnectConfig(
host = parsed.host, host = parsed.host,
port = parsed.port, port = parsed.port,
tls = parsed.tls, tls = parsed.tls,
bootstrapToken = "",
token = fallbackToken.trim(), token = fallbackToken.trim(),
password = fallbackPassword.trim(), password = fallbackPassword.trim(),
) )
@ -69,7 +86,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
if (raw.isEmpty()) return null if (raw.isEmpty()) return null
val normalized = if (raw.contains("://")) raw else "https://$raw" 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() val host = uri.host?.trim().orEmpty()
if (host.isEmpty()) return null if (host.isEmpty()) return null
@ -104,9 +121,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val obj = parseJsonObject(decoded) ?: return null val obj = parseJsonObject(decoded) ?: return null
val url = jsonField(obj, "url").orEmpty() val url = jsonField(obj, "url").orEmpty()
if (url.isEmpty()) return null if (url.isEmpty()) return null
val bootstrapToken = jsonField(obj, "bootstrapToken")
val token = jsonField(obj, "token") val token = jsonField(obj, "token")
val password = jsonField(obj, "password") val password = jsonField(obj, "password")
GatewaySetupCode(url = url, token = token, password = password) GatewaySetupCode(url = url, bootstrapToken = bootstrapToken, token = token, password = password)
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
null null
} }

View File

@ -772,8 +772,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
return@Button return@Button
} }
gatewayUrl = parsedSetup.url gatewayUrl = parsedSetup.url
parsedSetup.token?.let { viewModel.setGatewayToken(it) } viewModel.setGatewayBootstrapToken(parsedSetup.bootstrapToken.orEmpty())
gatewayPassword = parsedSetup.password.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 { } else {
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
@ -782,6 +792,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
return@Button return@Button
} }
gatewayUrl = parsedGateway.displayUrl gatewayUrl = parsedGateway.displayUrl
viewModel.setGatewayBootstrapToken("")
} }
step = OnboardingStep.Permissions step = OnboardingStep.Permissions
}, },
@ -850,8 +861,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
viewModel.setManualHost(parsed.host) viewModel.setManualHost(parsed.host)
viewModel.setManualPort(parsed.port) viewModel.setManualPort(parsed.port)
viewModel.setManualTls(parsed.tls) viewModel.setManualTls(parsed.tls)
if (gatewayInputMode == GatewayInputMode.Manual) {
viewModel.setGatewayBootstrapToken("")
}
if (token.isNotEmpty()) { if (token.isNotEmpty()) {
viewModel.setGatewayToken(token) viewModel.setGatewayToken(token)
} else {
viewModel.setGatewayToken("")
} }
viewModel.setGatewayPassword(password) viewModel.setGatewayPassword(password)
viewModel.connectManual() viewModel.connectManual()

View File

@ -20,4 +20,19 @@ class SecurePrefsTest {
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value) assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null)) 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)
}
} }

View File

@ -46,6 +46,7 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private data class NodeHarness( private data class NodeHarness(
val session: GatewaySession, val session: GatewaySession,
val sessionJob: Job, val sessionJob: Job,
val deviceAuthStore: InMemoryDeviceAuthStore,
) )
private data class InvokeScenarioResult( private data class InvokeScenarioResult(
@ -56,6 +57,93 @@ private data class InvokeScenarioResult(
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) @Config(sdk = [34])
class GatewaySessionInvokeTest { class GatewaySessionInvokeTest {
@Test
fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val connectAuth = CompletableDeferred<JsonObject?>()
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<Unit>()
val connectAuth = CompletableDeferred<JsonObject?>()
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 @Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
val handshakeOrigin = AtomicReference<String?>(null) val handshakeOrigin = AtomicReference<String?>(null)
@ -182,11 +270,12 @@ class GatewaySessionInvokeTest {
): NodeHarness { ): NodeHarness {
val app = RuntimeEnvironment.getApplication() val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob() val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session = val session =
GatewaySession( GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default), scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app), identityStore = DeviceIdentityStore(app),
deviceAuthStore = InMemoryDeviceAuthStore(), deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ -> onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit) if (!connected.isCompleted) connected.complete(Unit)
}, },
@ -197,10 +286,15 @@ class GatewaySessionInvokeTest {
onInvoke = onInvoke, 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( session.connect(
endpoint = endpoint =
GatewayEndpoint( GatewayEndpoint(
@ -210,7 +304,8 @@ class GatewaySessionInvokeTest {
port = port, port = port,
tlsEnabled = false, tlsEnabled = false,
), ),
token = "test-token", token = token,
bootstrapToken = bootstrapToken,
password = null, password = null,
options = options =
GatewayConnectOptions( GatewayConnectOptions(

View File

@ -8,7 +8,8 @@ import org.junit.Test
class GatewayConfigResolverTest { class GatewayConfigResolverTest {
@Test @Test
fun resolveScannedSetupCodeAcceptsRawSetupCode() { 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) val resolved = resolveScannedSetupCode(setupCode)
@ -17,7 +18,8 @@ class GatewayConfigResolverTest {
@Test @Test
fun resolveScannedSetupCodeAcceptsQrJsonPayload() { 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 = val qrJson =
""" """
{ {
@ -53,6 +55,43 @@ class GatewayConfigResolverTest {
assertNull(resolved) 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 { private fun encodeSetupCode(payloadJson: String): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
} }

View File

@ -14,6 +14,7 @@ struct GatewayConnectConfig: Sendable {
let stableID: String let stableID: String
let tls: GatewayTLSParams? let tls: GatewayTLSParams?
let token: String? let token: String?
let bootstrapToken: String?
let password: String? let password: String?
let nodeOptions: GatewayConnectOptions let nodeOptions: GatewayConnectOptions

View File

@ -101,6 +101,7 @@ final class GatewayConnectionController {
return "Missing instanceId (node.instanceId). Try restarting the app." return "Missing instanceId (node.instanceId). Try restarting the app."
} }
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
@ -151,6 +152,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID, gatewayStableID: stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password) password: password)
return nil return nil
} }
@ -163,6 +165,7 @@ final class GatewayConnectionController {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
@ -203,6 +206,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID, gatewayStableID: stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password) password: password)
} }
@ -229,6 +233,7 @@ final class GatewayConnectionController {
stableID: cfg.stableID, stableID: cfg.stableID,
tls: cfg.tls, tls: cfg.tls,
token: cfg.token, token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password, password: cfg.password,
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
appModel.applyGatewayConnectConfig(refreshedConfig) appModel.applyGatewayConnectConfig(refreshedConfig)
@ -261,6 +266,7 @@ final class GatewayConnectionController {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let tlsParams = GatewayTLSParams( let tlsParams = GatewayTLSParams(
required: true, required: true,
@ -274,6 +280,7 @@ final class GatewayConnectionController {
gatewayStableID: pending.stableID, gatewayStableID: pending.stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password) password: password)
} }
@ -319,6 +326,7 @@ final class GatewayConnectionController {
guard !instanceId.isEmpty else { return } guard !instanceId.isEmpty else { return }
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
if manualEnabled { if manualEnabled {
@ -353,6 +361,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID, gatewayStableID: stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password) password: password)
return return
} }
@ -379,6 +388,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID, gatewayStableID: stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password) password: password)
return return
} }
@ -448,6 +458,7 @@ final class GatewayConnectionController {
gatewayStableID: String, gatewayStableID: String,
tls: GatewayTLSParams?, tls: GatewayTLSParams?,
token: String?, token: String?,
bootstrapToken: String?,
password: String?) password: String?)
{ {
guard let appModel else { return } guard let appModel else { return }
@ -463,6 +474,7 @@ final class GatewayConnectionController {
stableID: gatewayStableID, stableID: gatewayStableID,
tls: tls, tls: tls,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password, password: password,
nodeOptions: connectOptions) nodeOptions: connectOptions)
appModel.applyGatewayConnectConfig(cfg) appModel.applyGatewayConnectConfig(cfg)

View File

@ -104,6 +104,21 @@ enum GatewaySettingsStore {
account: self.gatewayTokenAccount(instanceId: instanceId)) 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? { static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString( KeychainStore.loadString(
service: self.gatewayService, service: self.gatewayService,
@ -278,6 +293,9 @@ enum GatewaySettingsStore {
_ = KeychainStore.delete( _ = KeychainStore.delete(
service: self.gatewayService, service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: trimmed)) account: self.gatewayTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete( _ = KeychainStore.delete(
service: self.gatewayService, service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: trimmed)) account: self.gatewayPasswordAccount(instanceId: trimmed))
@ -331,6 +349,10 @@ enum GatewaySettingsStore {
"gateway-token.\(instanceId)" "gateway-token.\(instanceId)"
} }
private static func gatewayBootstrapTokenAccount(instanceId: String) -> String {
"gateway-bootstrap-token.\(instanceId)"
}
private static func gatewayPasswordAccount(instanceId: String) -> String { private static func gatewayPasswordAccount(instanceId: String) -> String {
"gateway-password.\(instanceId)" "gateway-password.\(instanceId)"
} }

View File

@ -5,6 +5,7 @@ struct GatewaySetupPayload: Codable {
var host: String? var host: String?
var port: Int? var port: Int?
var tls: Bool? var tls: Bool?
var bootstrapToken: String?
var token: String? var token: String?
var password: String? var password: String?
} }
@ -39,4 +40,3 @@ enum GatewaySetupCode {
return String(data: data, encoding: .utf8) return String(data: data, encoding: .utf8)
} }
} }

View File

@ -1680,6 +1680,7 @@ extension NodeAppModel {
gatewayStableID: String, gatewayStableID: String,
tls: GatewayTLSParams?, tls: GatewayTLSParams?,
token: String?, token: String?,
bootstrapToken: String?,
password: String?, password: String?,
connectOptions: GatewayConnectOptions) connectOptions: GatewayConnectOptions)
{ {
@ -1692,6 +1693,7 @@ extension NodeAppModel {
stableID: stableID, stableID: stableID,
tls: tls, tls: tls,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password, password: password,
nodeOptions: connectOptions) nodeOptions: connectOptions)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID) self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
@ -1699,6 +1701,7 @@ extension NodeAppModel {
url: url, url: url,
stableID: effectiveStableID, stableID: effectiveStableID,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password, password: password,
nodeOptions: connectOptions, nodeOptions: connectOptions,
sessionBox: sessionBox) sessionBox: sessionBox)
@ -1706,6 +1709,7 @@ extension NodeAppModel {
url: url, url: url,
stableID: effectiveStableID, stableID: effectiveStableID,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password, password: password,
nodeOptions: connectOptions, nodeOptions: connectOptions,
sessionBox: sessionBox) sessionBox: sessionBox)
@ -1721,6 +1725,7 @@ extension NodeAppModel {
gatewayStableID: cfg.stableID, gatewayStableID: cfg.stableID,
tls: cfg.tls, tls: cfg.tls,
token: cfg.token, token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password, password: cfg.password,
connectOptions: cfg.nodeOptions) connectOptions: cfg.nodeOptions)
} }
@ -1801,6 +1806,7 @@ private extension NodeAppModel {
url: URL, url: URL,
stableID: String, stableID: String,
token: String?, token: String?,
bootstrapToken: String?,
password: String?, password: String?,
nodeOptions: GatewayConnectOptions, nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?) sessionBox: WebSocketSessionBox?)
@ -1838,6 +1844,7 @@ private extension NodeAppModel {
try await self.operatorGateway.connect( try await self.operatorGateway.connect(
url: url, url: url,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password, password: password,
connectOptions: operatorOptions, connectOptions: operatorOptions,
sessionBox: sessionBox, sessionBox: sessionBox,
@ -1896,6 +1903,7 @@ private extension NodeAppModel {
url: URL, url: URL,
stableID: String, stableID: String,
token: String?, token: String?,
bootstrapToken: String?,
password: String?, password: String?,
nodeOptions: GatewayConnectOptions, nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?) sessionBox: WebSocketSessionBox?)
@ -1944,6 +1952,7 @@ private extension NodeAppModel {
try await self.nodeGateway.connect( try await self.nodeGateway.connect(
url: url, url: url,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password, password: password,
connectOptions: currentOptions, connectOptions: currentOptions,
sessionBox: sessionBox, sessionBox: sessionBox,

View File

@ -275,9 +275,21 @@ private struct ManualEntryStep: View {
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines) 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 { if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) 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." self.setupStatusText = "Setup code applied."

View File

@ -642,11 +642,17 @@ struct OnboardingWizardView: View {
self.manualHost = link.host self.manualHost = link.host
self.manualPort = link.port self.manualPort = link.port
self.manualTLS = link.tls 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 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 self.gatewayPassword = password
} else if trimmedBootstrapToken?.isEmpty == false {
self.gatewayPassword = ""
} }
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
self.showQRScanner = false self.showQRScanner = false
@ -794,6 +800,13 @@ struct OnboardingWizardView: View {
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) 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 { private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id self.connectingGatewayID = gateway.id
self.issue = .none self.issue = .none

View File

@ -767,12 +767,22 @@ struct SettingsTab: View {
} }
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) 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 { if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayToken = trimmedToken self.gatewayToken = trimmedToken
if !trimmedInstanceId.isEmpty { if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) 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 { if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
@ -780,6 +790,11 @@ struct SettingsTab: View {
if !trimmedInstanceId.isEmpty { if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
} }
} else if !trimmedBootstrapToken.isEmpty {
self.gatewayPassword = ""
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId)
}
} }
return true return true

View File

@ -86,7 +86,13 @@ private func agentAction(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")! string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
#expect( #expect(
DeepLinkParser.parse(url) == .gateway( 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() { @Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
@ -102,14 +108,15 @@ private func agentAction(
} }
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() { @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)) let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init( #expect(link == .init(
host: "gateway.example.com", host: "gateway.example.com",
port: 443, port: 443,
tls: true, tls: true,
token: "tok", bootstrapToken: "tok",
token: nil,
password: "pw")) password: "pw"))
} }
@ -118,38 +125,40 @@ private func agentAction(
} }
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { @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)) let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init( #expect(link == .init(
host: "gateway.example.com", host: "gateway.example.com",
port: 443, port: 443,
tls: true, tls: true,
token: "tok", bootstrapToken: "tok",
token: nil,
password: nil)) password: nil))
} }
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() { @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)) let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil) #expect(link == nil)
} }
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() { @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)) let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil) #expect(link == nil)
} }
@Test func parseGatewaySetupCodeAllowsLoopbackWs() { @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)) let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init( #expect(link == .init(
host: "127.0.0.1", host: "127.0.0.1",
port: 18789, port: 18789,
tls: false, tls: false,
token: "tok", bootstrapToken: "tok",
token: nil,
password: nil)) password: nil))
} }
} }

View File

@ -324,6 +324,8 @@ final class ControlChannel {
switch source { switch source {
case .deviceToken: case .deviceToken:
return "Auth: device token (paired device)" return "Auth: device token (paired device)"
case .bootstrapToken:
return "Auth: bootstrap token (setup code)"
case .sharedToken: case .sharedToken:
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
case .password: case .password:

View File

@ -77,6 +77,7 @@ final class MacNodeModeCoordinator {
try await self.session.connect( try await self.session.connect(
url: config.url, url: config.url,
token: config.token, token: config.token,
bootstrapToken: nil,
password: config.password, password: config.password,
connectOptions: connectOptions, connectOptions: connectOptions,
sessionBox: sessionBox, sessionBox: sessionBox,

View File

@ -508,6 +508,8 @@ extension OnboardingView {
return ("exclamationmark.triangle.fill", .orange) return ("exclamationmark.triangle.fill", .orange)
case .gatewayTokenNotConfigured: case .gatewayTokenNotConfigured:
return ("wrench.and.screwdriver.fill", .orange) return ("wrench.and.screwdriver.fill", .orange)
case .setupCodeExpired:
return ("qrcode.viewfinder", .orange)
case .passwordRequired: case .passwordRequired:
return ("lock.slash.fill", .orange) return ("lock.slash.fill", .orange)
case .pairingRequired: case .pairingRequired:

View File

@ -6,6 +6,7 @@ enum RemoteGatewayAuthIssue: Equatable {
case tokenRequired case tokenRequired
case tokenMismatch case tokenMismatch
case gatewayTokenNotConfigured case gatewayTokenNotConfigured
case setupCodeExpired
case passwordRequired case passwordRequired
case pairingRequired case pairingRequired
@ -20,6 +21,8 @@ enum RemoteGatewayAuthIssue: Equatable {
self = .tokenMismatch self = .tokenMismatch
case .authTokenNotConfigured: case .authTokenNotConfigured:
self = .gatewayTokenNotConfigured self = .gatewayTokenNotConfigured
case .authBootstrapTokenInvalid:
self = .setupCodeExpired
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured: case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
self = .passwordRequired self = .passwordRequired
case .pairingRequired: case .pairingRequired:
@ -33,7 +36,7 @@ enum RemoteGatewayAuthIssue: Equatable {
switch self { switch self {
case .tokenRequired, .tokenMismatch: case .tokenRequired, .tokenMismatch:
true true
case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired: case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired:
false false
} }
} }
@ -46,6 +49,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"That token did not match the gateway" "That token did not match the gateway"
case .gatewayTokenNotConfigured: case .gatewayTokenNotConfigured:
"This gateway host needs token setup" "This gateway host needs token setup"
case .setupCodeExpired:
"This setup code is no longer valid"
case .passwordRequired: case .passwordRequired:
"This gateway is using unsupported auth" "This gateway is using unsupported auth"
case .pairingRequired: case .pairingRequired:
@ -61,6 +66,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again." "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
case .gatewayTokenNotConfigured: 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." "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: case .passwordRequired:
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry." "This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
case .pairingRequired: case .pairingRequired:
@ -72,6 +79,8 @@ enum RemoteGatewayAuthIssue: Equatable {
switch self { switch self {
case .tokenRequired, .gatewayTokenNotConfigured: 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`." "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: case .pairingRequired:
"If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`." "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: 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." "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
case .gatewayTokenNotConfigured: case .gatewayTokenNotConfigured:
"This gateway has token auth enabled, but no gateway.auth.token is configured on the host." "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: case .passwordRequired:
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet." "This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
case .pairingRequired: case .pairingRequired:
@ -108,6 +119,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
switch self.authSource { switch self.authSource {
case .some(.deviceToken): case .some(.deviceToken):
"Connected via paired device" "Connected via paired device"
case .some(.bootstrapToken):
"Connected with setup code"
case .some(.sharedToken): case .some(.sharedToken):
"Connected with gateway token" "Connected with gateway token"
case .some(.password): case .some(.password):
@ -121,6 +134,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
switch self.authSource { switch self.authSource {
case .some(.deviceToken): case .some(.deviceToken):
"This Mac used a stored device token. New or unpaired devices may still need the gateway token." "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: case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
nil nil
} }

View File

@ -17,6 +17,10 @@ struct OnboardingRemoteAuthPromptTests {
message: "token not configured", message: "token not configured",
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue, detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
canRetryWithDeviceToken: false) canRetryWithDeviceToken: false)
let bootstrapInvalid = GatewayConnectAuthError(
message: "setup code expired",
detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
canRetryWithDeviceToken: false)
let passwordMissing = GatewayConnectAuthError( let passwordMissing = GatewayConnectAuthError(
message: "password missing", message: "password missing",
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue, detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
@ -33,6 +37,7 @@ struct OnboardingRemoteAuthPromptTests {
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired) #expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch) #expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured) #expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
#expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired)
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired) #expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired) #expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
#expect(RemoteGatewayAuthIssue(error: unknown) == nil) #expect(RemoteGatewayAuthIssue(error: unknown) == nil)
@ -88,6 +93,11 @@ struct OnboardingRemoteAuthPromptTests {
remoteToken: "", remoteToken: "",
remoteTokenUnsupported: false, remoteTokenUnsupported: false,
authIssue: .gatewayTokenNotConfigured) == false) authIssue: .gatewayTokenNotConfigured) == false)
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .setupCodeExpired) == false)
#expect(OnboardingView.shouldShowRemoteTokenField( #expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false, showAdvancedConnection: false,
remoteToken: "", remoteToken: "",
@ -106,11 +116,14 @@ struct OnboardingRemoteAuthPromptTests {
@Test func `paired device success copy explains auth source`() { @Test func `paired device success copy explains auth source`() {
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken) let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken)
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken) let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none) let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
#expect(pairedDevice.title == "Connected via paired device") #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(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.title == "Connected with gateway token")
#expect(sharedToken.detail == nil) #expect(sharedToken.detail == nil)
#expect(noAuth.title == "Remote gateway ready") #expect(noAuth.title == "Remote gateway ready")

View File

@ -9,13 +9,15 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String public let host: String
public let port: Int public let port: Int
public let tls: Bool public let tls: Bool
public let bootstrapToken: String?
public let token: String? public let token: String?
public let password: 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.host = host
self.port = port self.port = port
self.tls = tls self.tls = tls
self.bootstrapToken = bootstrapToken
self.token = token self.token = token
self.password = password self.password = password
} }
@ -25,7 +27,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return URL(string: "\(scheme)://\(self.host):\(self.port)") 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? { public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil } guard let data = Self.decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] 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 return nil
} }
let port = parsed.port ?? (tls ? 443 : 18789) let port = parsed.port ?? (tls ? 443 : 18789)
let bootstrapToken = json["bootstrapToken"] as? String
let token = json["token"] as? String let token = json["token"] as? String
let password = json["password"] 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? { private static func decodeBase64Url(_ input: String) -> Data? {
@ -140,6 +149,7 @@ public enum DeepLinkParser {
host: hostParam, host: hostParam,
port: port, port: port,
tls: tls, tls: tls,
bootstrapToken: nil,
token: query["token"], token: query["token"],
password: query["password"])) password: query["password"]))

View File

@ -112,6 +112,7 @@ public struct GatewayConnectOptions: Sendable {
public enum GatewayAuthSource: String, Sendable { public enum GatewayAuthSource: String, Sendable {
case deviceToken = "device-token" case deviceToken = "device-token"
case sharedToken = "shared-token" case sharedToken = "shared-token"
case bootstrapToken = "bootstrap-token"
case password = "password" case password = "password"
case none = "none" case none = "none"
} }
@ -131,6 +132,12 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing", "operator.pairing",
] ]
private extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
private enum GatewayConnectErrorCodes { private enum GatewayConnectErrorCodes {
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
@ -154,6 +161,7 @@ public actor GatewayChannelActor {
private var connectWaiters: [CheckedContinuation<Void, Error>] = [] private var connectWaiters: [CheckedContinuation<Void, Error>] = []
private var url: URL private var url: URL
private var token: String? private var token: String?
private var bootstrapToken: String?
private var password: String? private var password: String?
private let session: WebSocketSessioning private let session: WebSocketSessioning
private var backoffMs: Double = 500 private var backoffMs: Double = 500
@ -185,6 +193,7 @@ public actor GatewayChannelActor {
public init( public init(
url: URL, url: URL,
token: String?, token: String?,
bootstrapToken: String? = nil,
password: String? = nil, password: String? = nil,
session: WebSocketSessionBox? = nil, session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil, pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
@ -193,6 +202,7 @@ public actor GatewayChannelActor {
{ {
self.url = url self.url = url
self.token = token self.token = token
self.bootstrapToken = bootstrapToken
self.password = password self.password = password
self.session = session?.session ?? URLSession(configuration: .default) self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler self.pushHandler = pushHandler
@ -402,22 +412,29 @@ public actor GatewayChannelActor {
(includeDeviceIdentity && identity != nil) (includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token ? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: nil : 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 = let shouldUseDeviceRetryToken =
includeDeviceIdentity && self.pendingDeviceTokenRetry && includeDeviceIdentity && self.pendingDeviceTokenRetry &&
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint() storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
if shouldUseDeviceRetryToken { if shouldUseDeviceRetryToken {
self.pendingDeviceTokenRetry = false self.pendingDeviceTokenRetry = false
} }
// Keep shared credentials explicit when provided. Device token retry is attached // Keep shared credentials explicit when provided. Device token retry is attached
// only on a bounded second attempt after token mismatch. // 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 authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource let authSource: GatewayAuthSource
if authDeviceToken != nil || (self.token == nil && storedToken != nil) { if authDeviceToken != nil || (explicitToken == nil && storedToken != nil) {
authSource = .deviceToken authSource = .deviceToken
} else if authToken != nil { } else if authToken != nil {
authSource = .sharedToken authSource = .sharedToken
} else if self.password != nil { } else if authBootstrapToken != nil {
authSource = .bootstrapToken
} else if explicitPassword != nil {
authSource = .password authSource = .password
} else { } else {
authSource = .none authSource = .none
@ -430,7 +447,9 @@ public actor GatewayChannelActor {
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken) auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
} }
params["auth"] = ProtoAnyCodable(auth) 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)]) params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
} }
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
@ -443,7 +462,7 @@ public actor GatewayChannelActor {
role: role, role: role,
scopes: scopes, scopes: scopes,
signedAtMs: signedAtMs, signedAtMs: signedAtMs,
token: authToken, token: authToken ?? authBootstrapToken,
nonce: connectNonce, nonce: connectNonce,
platform: platform, platform: platform,
deviceFamily: InstanceIdentity.deviceFamily) deviceFamily: InstanceIdentity.deviceFamily)
@ -472,7 +491,7 @@ public actor GatewayChannelActor {
} catch { } catch {
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken( let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
error: error, error: error,
explicitGatewayToken: self.token, explicitGatewayToken: explicitToken,
storedToken: storedToken, storedToken: storedToken,
attemptedDeviceTokenRetry: authDeviceToken != nil) attemptedDeviceTokenRetry: authDeviceToken != nil)
if shouldRetryWithDeviceToken { if shouldRetryWithDeviceToken {

View File

@ -5,6 +5,7 @@ public enum GatewayConnectAuthDetailCode: String, Sendable {
case authRequired = "AUTH_REQUIRED" case authRequired = "AUTH_REQUIRED"
case authUnauthorized = "AUTH_UNAUTHORIZED" case authUnauthorized = "AUTH_UNAUTHORIZED"
case authTokenMismatch = "AUTH_TOKEN_MISMATCH" case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
case authBootstrapTokenInvalid = "AUTH_BOOTSTRAP_TOKEN_INVALID"
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH" case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
case authTokenMissing = "AUTH_TOKEN_MISSING" case authTokenMissing = "AUTH_TOKEN_MISSING"
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED" case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
@ -92,6 +93,7 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
public var isNonRecoverable: Bool { public var isNonRecoverable: Bool {
switch self.detail { switch self.detail {
case .authTokenMissing, case .authTokenMissing,
.authBootstrapTokenInvalid,
.authTokenNotConfigured, .authTokenNotConfigured,
.authPasswordMissing, .authPasswordMissing,
.authPasswordMismatch, .authPasswordMismatch,

View File

@ -64,6 +64,7 @@ public actor GatewayNodeSession {
private var channel: GatewayChannelActor? private var channel: GatewayChannelActor?
private var activeURL: URL? private var activeURL: URL?
private var activeToken: String? private var activeToken: String?
private var activeBootstrapToken: String?
private var activePassword: String? private var activePassword: String?
private var activeConnectOptionsKey: String? private var activeConnectOptionsKey: String?
private var connectOptions: GatewayConnectOptions? private var connectOptions: GatewayConnectOptions?
@ -194,6 +195,7 @@ public actor GatewayNodeSession {
public func connect( public func connect(
url: URL, url: URL,
token: String?, token: String?,
bootstrapToken: String?,
password: String?, password: String?,
connectOptions: GatewayConnectOptions, connectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?, sessionBox: WebSocketSessionBox?,
@ -204,6 +206,7 @@ public actor GatewayNodeSession {
let nextOptionsKey = self.connectOptionsKey(connectOptions) let nextOptionsKey = self.connectOptionsKey(connectOptions)
let shouldReconnect = self.activeURL != url || let shouldReconnect = self.activeURL != url ||
self.activeToken != token || self.activeToken != token ||
self.activeBootstrapToken != bootstrapToken ||
self.activePassword != password || self.activePassword != password ||
self.activeConnectOptionsKey != nextOptionsKey || self.activeConnectOptionsKey != nextOptionsKey ||
self.channel == nil self.channel == nil
@ -221,6 +224,7 @@ public actor GatewayNodeSession {
let channel = GatewayChannelActor( let channel = GatewayChannelActor(
url: url, url: url,
token: token, token: token,
bootstrapToken: bootstrapToken,
password: password, password: password,
session: sessionBox, session: sessionBox,
pushHandler: { [weak self] push in pushHandler: { [weak self] push in
@ -233,6 +237,7 @@ public actor GatewayNodeSession {
self.channel = channel self.channel = channel
self.activeURL = url self.activeURL = url
self.activeToken = token self.activeToken = token
self.activeBootstrapToken = bootstrapToken
self.activePassword = password self.activePassword = password
self.activeConnectOptionsKey = nextOptionsKey self.activeConnectOptionsKey = nextOptionsKey
} }
@ -257,6 +262,7 @@ public actor GatewayNodeSession {
self.channel = nil self.channel = nil
self.activeURL = nil self.activeURL = nil
self.activeToken = nil self.activeToken = nil
self.activeBootstrapToken = nil
self.activePassword = nil self.activePassword = nil
self.activeConnectOptionsKey = nil self.activeConnectOptionsKey = nil
self.hasEverConnected = false self.hasEverConnected = false

View File

@ -20,11 +20,17 @@ import Testing
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")! string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
#expect( #expect(
DeepLinkParser.parse(url) == .gateway( 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() { @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) let encoded = Data(payload.utf8)
.base64EncodedString() .base64EncodedString()
.replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "+", with: "-")
@ -34,7 +40,7 @@ import Testing
} }
@Test func setupCodeRejectsInsecurePrefixBypassHost() { @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) let encoded = Data(payload.utf8)
.base64EncodedString() .base64EncodedString()
.replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "+", with: "-")
@ -44,7 +50,7 @@ import Testing
} }
@Test func setupCodeAllowsLoopbackWs() { @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) let encoded = Data(payload.utf8)
.base64EncodedString() .base64EncodedString()
.replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "+", with: "-")
@ -55,7 +61,8 @@ import Testing
host: "127.0.0.1", host: "127.0.0.1",
port: 18789, port: 18789,
tls: false, tls: false,
token: "tok", bootstrapToken: "tok",
token: nil,
password: nil)) password: nil))
} }
} }

View File

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

View File

@ -266,6 +266,7 @@ struct GatewayNodeSessionTests {
try await gateway.connect( try await gateway.connect(
url: URL(string: "ws://example.invalid")!, url: URL(string: "ws://example.invalid")!,
token: nil, token: nil,
bootstrapToken: nil,
password: nil, password: nil,
connectOptions: options, connectOptions: options,
sessionBox: WebSocketSessionBox(session: session), sessionBox: WebSocketSessionBox(session: session),

View File

@ -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: The setup code is a base64-encoded JSON payload that contains:
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`) - `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. Treat the setup code like a password while it is valid.

View File

@ -17,7 +17,7 @@ openclaw qr
openclaw qr --setup-code-only openclaw qr --setup-code-only
openclaw qr --json openclaw qr --json
openclaw qr --remote openclaw qr --remote
openclaw qr --url wss://gateway.example/ws --token '<token>' openclaw qr --url wss://gateway.example/ws
``` ```
## Options ## Options
@ -25,8 +25,8 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
- `--remote`: use `gateway.remote.url` plus remote token/password from config - `--remote`: use `gateway.remote.url` plus remote token/password from config
- `--url <url>`: override gateway URL used in payload - `--url <url>`: override gateway URL used in payload
- `--public-url <url>`: override public URL used in payload - `--public-url <url>`: override public URL used in payload
- `--token <token>`: override gateway token for payload - `--token <token>`: override which gateway token the bootstrap flow authenticates against
- `--password <password>`: override gateway password for payload - `--password <password>`: override which gateway password the bootstrap flow authenticates against
- `--setup-code-only`: print only setup code - `--setup-code-only`: print only setup code
- `--no-ascii`: skip ASCII QR rendering - `--no-ascii`: skip ASCII QR rendering
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`) - `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`)
@ -34,6 +34,7 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
## Notes ## Notes
- `--token` and `--password` are mutually exclusive. - `--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. - 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: - 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). - `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins).

View File

@ -2,6 +2,7 @@ import os from "node:os";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair";
import { import {
approveDevicePairing, approveDevicePairing,
issueDeviceBootstrapToken,
listDevicePairing, listDevicePairing,
resolveGatewayBindUrl, resolveGatewayBindUrl,
runPluginCommandWithTimeout, runPluginCommandWithTimeout,
@ -31,8 +32,7 @@ type DevicePairPluginConfig = {
type SetupPayload = { type SetupPayload = {
url: string; url: string;
token?: string; bootstrapToken: string;
password?: string;
}; };
type ResolveUrlResult = { type ResolveUrlResult = {
@ -405,8 +405,14 @@ export default function register(api: OpenClawPluginApi) {
const payload: SetupPayload = { const payload: SetupPayload = {
url: urlResult.url, url: urlResult.url,
token: auth.token, bootstrapToken: (
password: auth.password, 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") { if (action === "qr") {

View File

@ -27,6 +27,12 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWi
vi.mock("./command-secret-gateway.js", () => ({ vi.mock("./command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
})); }));
vi.mock("../infra/device-bootstrap.js", () => ({
issueDeviceBootstrapToken: vi.fn(async () => ({
token: "bootstrap-123",
expiresAtMs: 123,
})),
}));
vi.mock("qrcode-terminal", () => ({ vi.mock("qrcode-terminal", () => ({
default: { default: {
generate: mocks.qrGenerate, generate: mocks.qrGenerate,
@ -156,7 +162,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789", url: "ws://gateway.local:18789",
token: "tok", bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
expect(qrGenerate).not.toHaveBeenCalled(); expect(qrGenerate).not.toHaveBeenCalled();
@ -194,7 +200,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789", url: "ws://gateway.local:18789",
token: "override-token", bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
}); });
@ -210,7 +216,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789", url: "ws://gateway.local:18789",
token: "override-token", bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
}); });
@ -227,7 +233,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789", url: "ws://gateway.local:18789",
password: "local-password-secret", // pragma: allowlist secret bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -245,7 +251,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789", url: "ws://gateway.local:18789",
password: "password-from-env", // pragma: allowlist secret bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -264,7 +270,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789", url: "ws://gateway.local:18789",
token: "token-123", bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -282,7 +288,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789", url: "ws://gateway.local:18789",
password: "inferred-password", // pragma: allowlist secret bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -332,7 +338,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444", url: "wss://remote.example.com:444",
token: "remote-tok", bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
@ -375,7 +381,7 @@ describe("registerQrCli", () => {
).toBe(true); ).toBe(true);
const expected = encodePairingSetupCode({ const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444", url: "wss://remote.example.com:444",
token: "remote-tok", bootstrapToken: "bootstrap-123",
}); });
expect(runtime.log).toHaveBeenCalledWith(expected); expect(runtime.log).toHaveBeenCalledWith(expected);
}); });

View File

@ -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 padded = setupCode.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (padded.length % 4)) % 4; const padLength = (4 - (padded.length % 4)) % 4;
const normalized = padded + "=".repeat(padLength); const normalized = padded + "=".repeat(padLength);
const json = Buffer.from(normalized, "base64").toString("utf8"); 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<void> { async function runCli(args: string[]): Promise<void> {
@ -126,7 +136,8 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
expect(setupCode).toBeTruthy(); expect(setupCode).toBeTruthy();
const payload = decodeSetupCode(setupCode ?? ""); const payload = decodeSetupCode(setupCode ?? "");
expect(payload.url).toBe("ws://gateway.local:18789"); 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([]); expect(runtimeErrors).toEqual([]);
runtimeLogs.length = 0; runtimeLogs.length = 0;

View File

@ -39,7 +39,14 @@ export type ResolvedGatewayAuth = {
export type GatewayAuthResult = { export type GatewayAuthResult = {
ok: boolean; ok: boolean;
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy"; method?:
| "none"
| "token"
| "password"
| "tailscale"
| "device-token"
| "bootstrap-token"
| "trusted-proxy";
user?: string; user?: string;
reason?: string; reason?: string;
/** Present when the request was blocked by the rate limiter. */ /** Present when the request was blocked by the rate limiter. */

View File

@ -335,6 +335,7 @@ describe("GatewayClient connect auth payload", () => {
params?: { params?: {
auth?: { auth?: {
token?: string; token?: string;
bootstrapToken?: string;
deviceToken?: string; deviceToken?: string;
password?: string; password?: string;
}; };
@ -410,6 +411,26 @@ describe("GatewayClient connect auth payload", () => {
client.stop(); 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", () => { it("prefers explicit deviceToken over stored device token", () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({ const client = new GatewayClient({

View File

@ -69,6 +69,7 @@ export type GatewayClientOptions = {
connectDelayMs?: number; connectDelayMs?: number;
tickWatchMinIntervalMs?: number; tickWatchMinIntervalMs?: number;
token?: string; token?: string;
bootstrapToken?: string;
deviceToken?: string; deviceToken?: string;
password?: string; password?: string;
instanceId?: string; instanceId?: string;
@ -281,6 +282,7 @@ export class GatewayClient {
} }
const role = this.opts.role ?? "operator"; const role = this.opts.role ?? "operator";
const explicitGatewayToken = this.opts.token?.trim() || undefined; const explicitGatewayToken = this.opts.token?.trim() || undefined;
const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined;
const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined; const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined;
const storedToken = this.opts.deviceIdentity const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
@ -294,21 +296,27 @@ export class GatewayClient {
if (shouldUseDeviceRetryToken) { if (shouldUseDeviceRetryToken) {
this.pendingDeviceTokenRetry = false; this.pendingDeviceTokenRetry = false;
} }
// Keep shared gateway credentials explicit. Persisted per-device tokens only // Shared gateway credentials stay explicit. Bootstrap tokens are different:
// participate when no explicit shared token/password is provided. // once a role-scoped device token exists, it should take precedence so the
// temporary bootstrap secret falls out of active use.
const resolvedDeviceToken = const resolvedDeviceToken =
explicitDeviceToken ?? explicitDeviceToken ??
(shouldUseDeviceRetryToken || !(explicitGatewayToken || this.opts.password?.trim()) (shouldUseDeviceRetryToken ||
(!(explicitGatewayToken || this.opts.password?.trim()) &&
(!explicitBootstrapToken || Boolean(storedToken)))
? (storedToken ?? undefined) ? (storedToken ?? undefined)
: undefined); : undefined);
// Legacy compatibility: keep `auth.token` populated for device-token auth when // Legacy compatibility: keep `auth.token` populated for device-token auth when
// no explicit shared token is present. // no explicit shared token is present.
const authToken = explicitGatewayToken ?? resolvedDeviceToken; const authToken = explicitGatewayToken ?? resolvedDeviceToken;
const authBootstrapToken =
!explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined;
const authPassword = this.opts.password?.trim() || undefined; const authPassword = this.opts.password?.trim() || undefined;
const auth = const auth =
authToken || authPassword || resolvedDeviceToken authToken || authBootstrapToken || authPassword || resolvedDeviceToken
? { ? {
token: authToken, token: authToken,
bootstrapToken: authBootstrapToken,
deviceToken: resolvedDeviceToken, deviceToken: resolvedDeviceToken,
password: authPassword, password: authPassword,
} }
@ -327,7 +335,7 @@ export class GatewayClient {
role, role,
scopes, scopes,
signedAtMs, signedAtMs,
token: authToken ?? null, token: authToken ?? authBootstrapToken ?? null,
nonce, nonce,
platform, platform,
deviceFamily: this.opts.deviceFamily, deviceFamily: this.opts.deviceFamily,
@ -420,6 +428,7 @@ export class GatewayClient {
} }
if ( if (
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||

View File

@ -7,6 +7,7 @@ export const ConnectErrorDetailCodes = {
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", // pragma: allowlist secret AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", // pragma: allowlist secret
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", // pragma: allowlist secret AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", // pragma: allowlist secret
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED", // 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_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED", AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING", AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
@ -64,6 +65,8 @@ export function resolveAuthConnectErrorDetailCode(
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH; return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
case "password_missing_config": case "password_missing_config":
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED; return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
case "bootstrap_token_invalid":
return ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID;
case "tailscale_user_missing": case "tailscale_user_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING; return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
case "tailscale_proxy_missing": case "tailscale_proxy_missing":

View File

@ -56,6 +56,7 @@ export const ConnectParamsSchema = Type.Object(
Type.Object( Type.Object(
{ {
token: Type.Optional(Type.String()), token: Type.Optional(Type.String()),
bootstrapToken: Type.Optional(Type.String()),
deviceToken: Type.Optional(Type.String()), deviceToken: Type.Optional(Type.String()),
password: Type.Optional(Type.String()), password: Type.Optional(Type.String()),
}, },

View File

@ -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", () => { it("blocks reconnect for AUTH_PASSWORD_MISSING", () => {
expect( expect(
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)), isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)),

View File

@ -3,6 +3,9 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js"; import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"]; type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"];
type VerifyBootstrapTokenFn = Parameters<
typeof resolveConnectAuthDecision
>[0]["verifyBootstrapToken"];
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): { function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
limiter: AuthRateLimiter; limiter: AuthRateLimiter;
@ -38,6 +41,7 @@ function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthStat
async function resolveDeviceTokenDecision(params: { async function resolveDeviceTokenDecision(params: {
verifyDeviceToken: VerifyDeviceTokenFn; verifyDeviceToken: VerifyDeviceTokenFn;
verifyBootstrapToken?: VerifyBootstrapTokenFn;
stateOverrides?: Partial<ConnectAuthState>; stateOverrides?: Partial<ConnectAuthState>;
rateLimiter?: AuthRateLimiter; rateLimiter?: AuthRateLimiter;
clientIp?: string; clientIp?: string;
@ -46,8 +50,12 @@ async function resolveDeviceTokenDecision(params: {
state: createBaseState(params.stateOverrides), state: createBaseState(params.stateOverrides),
hasDeviceIdentity: true, hasDeviceIdentity: true,
deviceId: "dev-1", deviceId: "dev-1",
publicKey: "pub-1",
role: "operator", role: "operator",
scopes: ["operator.read"], scopes: ["operator.read"],
verifyBootstrapToken:
params.verifyBootstrapToken ??
(async () => ({ ok: false, reason: "bootstrap_token_invalid" })),
verifyDeviceToken: params.verifyDeviceToken, verifyDeviceToken: params.verifyDeviceToken,
...(params.rateLimiter ? { rateLimiter: params.rateLimiter } : {}), ...(params.rateLimiter ? { rateLimiter: params.rateLimiter } : {}),
...(params.clientIp ? { clientIp: params.clientIp } : {}), ...(params.clientIp ? { clientIp: params.clientIp } : {}),
@ -57,16 +65,23 @@ async function resolveDeviceTokenDecision(params: {
describe("resolveConnectAuthDecision", () => { describe("resolveConnectAuthDecision", () => {
it("keeps shared-secret mismatch when fallback device-token check fails", async () => { it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false })); const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
ok: false,
reason: "bootstrap_token_invalid",
}));
const decision = await resolveConnectAuthDecision({ const decision = await resolveConnectAuthDecision({
state: createBaseState(), state: createBaseState(),
hasDeviceIdentity: true, hasDeviceIdentity: true,
deviceId: "dev-1", deviceId: "dev-1",
publicKey: "pub-1",
role: "operator", role: "operator",
scopes: ["operator.read"], scopes: ["operator.read"],
verifyBootstrapToken,
verifyDeviceToken, verifyDeviceToken,
}); });
expect(decision.authOk).toBe(false); expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("token_mismatch"); expect(decision.authResult.reason).toBe("token_mismatch");
expect(verifyBootstrapToken).not.toHaveBeenCalled();
expect(verifyDeviceToken).toHaveBeenCalledOnce(); expect(verifyDeviceToken).toHaveBeenCalledOnce();
}); });
@ -78,8 +93,10 @@ describe("resolveConnectAuthDecision", () => {
}), }),
hasDeviceIdentity: true, hasDeviceIdentity: true,
deviceId: "dev-1", deviceId: "dev-1",
publicKey: "pub-1",
role: "operator", role: "operator",
scopes: ["operator.read"], scopes: ["operator.read"],
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
verifyDeviceToken, verifyDeviceToken,
}); });
expect(decision.authOk).toBe(false); expect(decision.authOk).toBe(false);
@ -100,6 +117,44 @@ describe("resolveConnectAuthDecision", () => {
expect(rateLimiter.reset).toHaveBeenCalledOnce(); expect(rateLimiter.reset).toHaveBeenCalledOnce();
}); });
it("accepts valid bootstrap tokens before device-token fallback", async () => {
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(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<VerifyBootstrapTokenFn>(async () => ({
ok: false,
reason: "bootstrap_token_invalid",
}));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(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 () => { it("returns rate-limited auth result without verifying device token", async () => {
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 }); const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true })); const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
@ -123,8 +178,10 @@ describe("resolveConnectAuthDecision", () => {
}), }),
hasDeviceIdentity: true, hasDeviceIdentity: true,
deviceId: "dev-1", deviceId: "dev-1",
publicKey: "pub-1",
role: "operator", role: "operator",
scopes: [], scopes: [],
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
verifyDeviceToken, verifyDeviceToken,
}); });
expect(decision.authOk).toBe(true); expect(decision.authOk).toBe(true);

View File

@ -14,6 +14,7 @@ import {
type HandshakeConnectAuth = { type HandshakeConnectAuth = {
token?: string; token?: string;
bootstrapToken?: string;
deviceToken?: string; deviceToken?: string;
password?: string; password?: string;
}; };
@ -26,11 +27,13 @@ export type ConnectAuthState = {
authMethod: GatewayAuthResult["method"]; authMethod: GatewayAuthResult["method"];
sharedAuthOk: boolean; sharedAuthOk: boolean;
sharedAuthProvided: boolean; sharedAuthProvided: boolean;
bootstrapTokenCandidate?: string;
deviceTokenCandidate?: string; deviceTokenCandidate?: string;
deviceTokenCandidateSource?: DeviceTokenCandidateSource; deviceTokenCandidateSource?: DeviceTokenCandidateSource;
}; };
type VerifyDeviceTokenResult = { ok: boolean }; type VerifyDeviceTokenResult = { ok: boolean };
type VerifyBootstrapTokenResult = { ok: boolean; reason?: string };
export type ConnectAuthDecision = { export type ConnectAuthDecision = {
authResult: GatewayAuthResult; authResult: GatewayAuthResult;
@ -72,6 +75,12 @@ function resolveDeviceTokenCandidate(connectAuth: HandshakeConnectAuth | null |
return { token: fallbackToken, source: "shared-token-fallback" }; return { token: fallbackToken, source: "shared-token-fallback" };
} }
function resolveBootstrapTokenCandidate(
connectAuth: HandshakeConnectAuth | null | undefined,
): string | undefined {
return trimToUndefined(connectAuth?.bootstrapToken);
}
export async function resolveConnectAuthState(params: { export async function resolveConnectAuthState(params: {
resolvedAuth: ResolvedGatewayAuth; resolvedAuth: ResolvedGatewayAuth;
connectAuth: HandshakeConnectAuth | null | undefined; connectAuth: HandshakeConnectAuth | null | undefined;
@ -84,6 +93,9 @@ export async function resolveConnectAuthState(params: {
}): Promise<ConnectAuthState> { }): Promise<ConnectAuthState> {
const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth); const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth);
const sharedAuthProvided = Boolean(sharedConnectAuth); const sharedAuthProvided = Boolean(sharedConnectAuth);
const bootstrapTokenCandidate = params.hasDeviceIdentity
? resolveBootstrapTokenCandidate(params.connectAuth)
: undefined;
const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } = const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } =
params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {}; params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate); const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate);
@ -148,6 +160,7 @@ export async function resolveConnectAuthState(params: {
authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"), authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"),
sharedAuthOk, sharedAuthOk,
sharedAuthProvided, sharedAuthProvided,
bootstrapTokenCandidate,
deviceTokenCandidate, deviceTokenCandidate,
deviceTokenCandidateSource, deviceTokenCandidateSource,
}; };
@ -157,10 +170,18 @@ export async function resolveConnectAuthDecision(params: {
state: ConnectAuthState; state: ConnectAuthState;
hasDeviceIdentity: boolean; hasDeviceIdentity: boolean;
deviceId?: string; deviceId?: string;
publicKey?: string;
role: string; role: string;
scopes: string[]; scopes: string[];
rateLimiter?: AuthRateLimiter; rateLimiter?: AuthRateLimiter;
clientIp?: string; clientIp?: string;
verifyBootstrapToken: (params: {
deviceId: string;
publicKey: string;
token: string;
role: string;
scopes: string[];
}) => Promise<VerifyBootstrapTokenResult>;
verifyDeviceToken: (params: { verifyDeviceToken: (params: {
deviceId: string; deviceId: string;
token: string; token: string;
@ -172,6 +193,29 @@ export async function resolveConnectAuthDecision(params: {
let authOk = params.state.authOk; let authOk = params.state.authOk;
let authMethod = params.state.authMethod; 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; const deviceTokenCandidate = params.state.deviceTokenCandidate;
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) { if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
return { authResult, authOk, authMethod }; return { authResult, authOk, authMethod };

View File

@ -2,7 +2,7 @@ import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-chan
import type { ResolvedGatewayAuth } from "../../auth.js"; import type { ResolvedGatewayAuth } from "../../auth.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.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: { export function formatGatewayAuthFailureMessage(params: {
authMode: ResolvedGatewayAuth["mode"]; authMode: ResolvedGatewayAuth["mode"];
@ -38,6 +38,8 @@ export function formatGatewayAuthFailureMessage(params: {
return `unauthorized: gateway password mismatch (${passwordHint})`; return `unauthorized: gateway password mismatch (${passwordHint})`;
case "password_missing_config": case "password_missing_config":
return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)"; 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": case "tailscale_user_missing":
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)"; return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
case "tailscale_proxy_missing": case "tailscale_proxy_missing":
@ -60,6 +62,9 @@ export function formatGatewayAuthFailureMessage(params: {
if (authMode === "token" && authProvided === "device-token") { if (authMode === "token" && authProvided === "device-token") {
return "unauthorized: device token rejected (pair/repair this device, or provide gateway 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") { if (authMode === "password" && authProvided === "none") {
return `unauthorized: gateway password missing (${passwordHint})`; return `unauthorized: gateway password missing (${passwordHint})`;
} }

View File

@ -2,6 +2,7 @@ import type { IncomingMessage } from "node:http";
import os from "node:os"; import os from "node:os";
import type { WebSocket } from "ws"; import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js"; import { loadConfig } from "../../../config/config.js";
import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js";
import { import {
deriveDeviceIdFromPublicKey, deriveDeviceIdFromPublicKey,
normalizeDevicePublicKeyBase64Url, normalizeDevicePublicKeyBase64Url,
@ -186,7 +187,11 @@ function resolveDeviceSignaturePayloadVersion(params: {
role: params.role, role: params.role,
scopes: params.scopes, scopes: params.scopes,
signedAtMs: params.signedAtMs, 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, nonce: params.nonce,
platform: params.connectParams.client.platform, platform: params.connectParams.client.platform,
deviceFamily: params.connectParams.client.deviceFamily, deviceFamily: params.connectParams.client.deviceFamily,
@ -202,7 +207,11 @@ function resolveDeviceSignaturePayloadVersion(params: {
role: params.role, role: params.role,
scopes: params.scopes, scopes: params.scopes,
signedAtMs: params.signedAtMs, 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, nonce: params.nonce,
}); });
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) { if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
@ -566,6 +575,7 @@ export function attachGatewayWsMessageHandler(params: {
authOk, authOk,
authMethod, authMethod,
sharedAuthOk, sharedAuthOk,
bootstrapTokenCandidate,
deviceTokenCandidate, deviceTokenCandidate,
deviceTokenCandidateSource, deviceTokenCandidateSource,
} = await resolveConnectAuthState({ } = await resolveConnectAuthState({
@ -610,9 +620,11 @@ export function attachGatewayWsMessageHandler(params: {
? "password" ? "password"
: connectParams.auth?.token : connectParams.auth?.token
? "token" ? "token"
: connectParams.auth?.deviceToken : connectParams.auth?.bootstrapToken
? "device-token" ? "bootstrap-token"
: "none", : connectParams.auth?.deviceToken
? "device-token"
: "none",
authReason: failedAuth.reason, authReason: failedAuth.reason,
allowTailscale: resolvedAuth.allowTailscale, allowTailscale: resolvedAuth.allowTailscale,
}); });
@ -623,9 +635,11 @@ export function attachGatewayWsMessageHandler(params: {
? "password" ? "password"
: connectParams.auth?.token : connectParams.auth?.token
? "token" ? "token"
: connectParams.auth?.deviceToken : connectParams.auth?.bootstrapToken
? "device-token" ? "bootstrap-token"
: "none"; : connectParams.auth?.deviceToken
? "device-token"
: "none";
const authMessage = formatGatewayAuthFailureMessage({ const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode, authMode: resolvedAuth.mode,
authProvided, authProvided,
@ -774,15 +788,25 @@ export function attachGatewayWsMessageHandler(params: {
authMethod, authMethod,
sharedAuthOk, sharedAuthOk,
sharedAuthProvided: hasSharedAuth, sharedAuthProvided: hasSharedAuth,
bootstrapTokenCandidate,
deviceTokenCandidate, deviceTokenCandidate,
deviceTokenCandidateSource, deviceTokenCandidateSource,
}, },
hasDeviceIdentity: Boolean(device), hasDeviceIdentity: Boolean(device),
deviceId: device?.id, deviceId: device?.id,
publicKey: device?.publicKey,
role, role,
scopes, scopes,
rateLimiter: authRateLimiter, rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp, clientIp: browserRateLimitClientIp,
verifyBootstrapToken: async ({ deviceId, publicKey, token, role, scopes }) =>
await verifyDeviceBootstrapToken({
deviceId,
publicKey,
token,
role,
scopes,
}),
verifyDeviceToken, verifyDeviceToken,
})); }));
if (!authOk) { if (!authOk) {

View File

@ -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<string> {
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" });
});
});

View File

@ -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<string, DeviceBootstrapTokenRecord>;
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<string>(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<string>(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<DeviceBootstrapStateFile> {
const bootstrapPath = resolveBootstrapPath(baseDir);
const state = (await readJsonFile<DeviceBootstrapStateFile>(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<void> {
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 };
});
}

View File

@ -2,6 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SecretInput } from "../config/types.secrets.js"; import type { SecretInput } from "../config/types.secrets.js";
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.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", () => { describe("pairing setup code", () => {
function createTailnetDnsRunner() { function createTailnetDnsRunner() {
return vi.fn(async () => ({ return vi.fn(async () => ({
@ -25,10 +32,12 @@ describe("pairing setup code", () => {
it("encodes payload as base64url JSON", () => { it("encodes payload as base64url JSON", () => {
const code = encodePairingSetupCode({ const code = encodePairingSetupCode({
url: "wss://gateway.example.com:443", 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 () => { it("resolves custom bind + token auth", async () => {
@ -45,8 +54,7 @@ describe("pairing setup code", () => {
ok: true, ok: true,
payload: { payload: {
url: "ws://gateway.local:19001", url: "ws://gateway.local:19001",
token: "tok_123", bootstrapToken: "bootstrap-123",
password: undefined,
}, },
authLabel: "token", authLabel: "token",
urlSource: "gateway.bind=custom", urlSource: "gateway.bind=custom",
@ -81,7 +89,7 @@ describe("pairing setup code", () => {
if (!resolved.ok) { if (!resolved.ok) {
throw new Error("expected setup resolution to succeed"); 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"); expect(resolved.authLabel).toBe("password");
}); });
@ -113,7 +121,7 @@ describe("pairing setup code", () => {
if (!resolved.ok) { if (!resolved.ok) {
throw new Error("expected setup resolution to succeed"); 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"); expect(resolved.authLabel).toBe("password");
}); });
@ -145,7 +153,7 @@ describe("pairing setup code", () => {
throw new Error("expected setup resolution to succeed"); throw new Error("expected setup resolution to succeed");
} }
expect(resolved.authLabel).toBe("token"); 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 () => { 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"); throw new Error("expected setup resolution to succeed");
} }
expect(resolved.authLabel).toBe("token"); 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 () => { 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"); throw new Error("expected setup resolution to succeed");
} }
expect(resolved.authLabel).toBe("password"); 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 () => { 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"); throw new Error("expected setup resolution to succeed");
} }
expect(resolved.authLabel).toBe("password"); expect(resolved.authLabel).toBe("password");
expect(resolved.payload.token).toBeUndefined(); expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
expect(resolved.payload.password).toBe("password-from-env");
}); });
it("requires explicit auth mode when token and password are both configured", async () => { it("requires explicit auth mode when token and password are both configured", async () => {
@ -329,7 +336,7 @@ describe("pairing setup code", () => {
if (!resolved.ok) { if (!resolved.ok) {
throw new Error("expected setup resolution to succeed"); 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 () => { it("errors when gateway is loopback only", async () => {
@ -366,8 +373,7 @@ describe("pairing setup code", () => {
ok: true, ok: true,
payload: { payload: {
url: "wss://mb-server.tailnet.ts.net", url: "wss://mb-server.tailnet.ts.net",
token: undefined, bootstrapToken: "bootstrap-123",
password: "secret",
}, },
authLabel: "password", authLabel: "password",
urlSource: "gateway.tailscale.mode=serve", urlSource: "gateway.tailscale.mode=serve",
@ -395,8 +401,7 @@ describe("pairing setup code", () => {
ok: true, ok: true,
payload: { payload: {
url: "wss://remote.example.com:444", url: "wss://remote.example.com:444",
token: "tok_123", bootstrapToken: "bootstrap-123",
password: undefined,
}, },
authLabel: "token", authLabel: "token",
urlSource: "gateway.remote.url", urlSource: "gateway.remote.url",

View File

@ -8,14 +8,14 @@ import {
} from "../config/types.secrets.js"; } from "../config/types.secrets.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.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 { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js"; import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
export type PairingSetupPayload = { export type PairingSetupPayload = {
url: string; url: string;
token?: string; bootstrapToken: string;
password?: string;
}; };
export type PairingSetupCommandResult = { export type PairingSetupCommandResult = {
@ -34,6 +34,7 @@ export type ResolvePairingSetupOptions = {
publicUrl?: string; publicUrl?: string;
preferRemoteUrl?: boolean; preferRemoteUrl?: boolean;
forceSecure?: boolean; forceSecure?: boolean;
pairingBaseDir?: string;
runCommandWithTimeout?: PairingSetupCommandRunner; runCommandWithTimeout?: PairingSetupCommandRunner;
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>; networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
}; };
@ -388,8 +389,11 @@ export async function resolvePairingSetupFromConfig(
ok: true, ok: true,
payload: { payload: {
url: urlResult.url, url: urlResult.url,
token: auth.token, bootstrapToken: (
password: auth.password, await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
})
).token,
}, },
authLabel: auth.label, authLabel: auth.label,
urlSource: urlResult.source ?? "unknown", urlSource: urlResult.source ?? "unknown",

View File

@ -2,6 +2,7 @@
// Keep this list additive and scoped to symbols used under extensions/device-pair. // Keep this list additive and scoped to symbols used under extensions/device-pair.
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
export type { OpenClawPluginApi } from "../plugins/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js";
export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";

View File

@ -69,6 +69,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
const code = resolveGatewayErrorDetailCode(error); const code = resolveGatewayErrorDetailCode(error);
return ( return (
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||