refactor(gateway)!: remove legacy v1 device-auth handshake

This commit is contained in:
Peter Steinberger
2026-02-22 09:26:49 +01:00
parent ed38b50fa5
commit 8887f41d7d
17 changed files with 404 additions and 210 deletions

View File

@@ -178,7 +178,7 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val connectNonceDeferred = CompletableDeferred<String>()
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "OpenClawGateway"
@@ -296,7 +296,7 @@ class GatewaySession(
}
}
private suspend fun sendConnect(connectNonce: String?) {
private suspend fun sendConnect(connectNonce: String) {
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
@@ -332,7 +332,7 @@ class GatewaySession(
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String?,
connectNonce: String,
authToken: String,
authPassword: String?,
): JsonObject {
@@ -385,9 +385,7 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
put("nonce", JsonPrimitive(connectNonce))
}
} else {
null
@@ -447,8 +445,8 @@ class GatewaySession(
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
connectNonceDeferred.complete(nonce.trim())
}
return
}
@@ -459,12 +457,11 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
private suspend fun awaitConnectNonce(): String {
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
} catch (err: Throwable) {
throw IllegalStateException("connect challenge timeout", err)
}
}
@@ -595,14 +592,13 @@ class GatewaySession(
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String?,
nonce: String,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
val parts =
mutableListOf(
version,
"v2",
deviceId,
clientId,
clientMode,
@@ -610,10 +606,8 @@ class GatewaySession(
scopeString,
signedAtMs.toString(),
authToken,
nonce,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
}

View File

@@ -281,8 +281,8 @@ actor GatewayWizardClient {
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
let payloadParts = [
"v2",
identity.deviceId,
clientId,
clientMode,
@@ -290,23 +290,19 @@ actor GatewayWizardClient {
scopesValue,
String(signedAtMs),
self.token ?? "",
connectNonce,
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{
var device: [String: ProtoAnyCodable] = [
let device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
"nonce": ProtoAnyCodable(connectNonce),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
@@ -333,29 +329,24 @@ actor GatewayWizardClient {
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String
{
return nonce
}
}
private func waitForConnectChallenge() async throws -> String {
guard let task = self.task else { throw ConnectChallengeError.timeout }
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String,
nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
return nonce
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
}
})
}
}

View File

@@ -146,8 +146,8 @@ public actor GatewayChannelActor {
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
// Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event,
// and we must include the nonce once the gateway requires v2 signing.
// Remote gateways (tailscale/wan) can take longer to deliver connect.challenge.
// Connect now requires this nonce before we send device-auth.
private let connectTimeoutSeconds: Double = 12
private let connectChallengeTimeoutSeconds: Double = 6.0
// Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client,
@@ -391,8 +391,8 @@ public actor GatewayChannelActor {
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge()
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
let payloadParts = [
"v2",
identity?.deviceId ?? "",
clientId,
clientMode,
@@ -400,23 +400,19 @@ public actor GatewayChannelActor {
scopesValue,
String(signedAtMs),
authToken ?? "",
connectNonce,
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if includeDeviceIdentity, let identity {
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
var device: [String: ProtoAnyCodable] = [
let device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
"nonce": ProtoAnyCodable(connectNonce),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
}
@@ -545,33 +541,26 @@ public actor GatewayChannelActor {
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { return nil }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
}
private func waitForConnectChallenge() async throws -> String {
guard let task = self.task else { throw ConnectChallengeError.timeout }
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { throw ConnectChallengeError.timeout }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String,
nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
return nonce
}
})
} catch {
if error is ConnectChallengeError {
self.logger.warning("gateway connect challenge timed out")
return nil
}
throw error
}
}
})
}
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {