diff --git a/CHANGELOG.md b/CHANGELOG.md index 200599d6adb..ba9bd406d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai - Gateway/auth: reject mismatched browser `Origin` headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc. - Gateway/device tokens: disconnect active device sessions after token rotation so newly rotated credentials revoke existing live connections immediately instead of waiting for those sockets to close naturally. Thanks @zsxsoft and @vincentkoc. - Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u. +- Gateway/pairing: restore QR bootstrap onboarding handoff so fresh `/pair qr` iPhone setup can auto-approve the initial node pairing, receive a reusable node device token, and stop retrying with spent bootstrap auth. (#58382) Thanks @ngutman. - Gateway/OpenAI compatibility: accept flat Responses API function tool definitions on `/v1/responses` and preserve `strict` when normalizing hosted tools into the embedded runner, so spec-compliant clients like Codex no longer fail validation or silently lose strict tool enforcement. Thanks @malaiwah and @vincentkoc. - Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit `x-openclaw-scopes`, so headless `/v1/chat/completions` and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf. - Gateway/plugins: scope plugin-auth HTTP route runtime clients to read-only access and keep gateway-authenticated plugin routes on write scope, so plugin-owned webhook handlers do not inherit write-capable runtime access by default. Thanks @davidluzsilva and @vincentkoc. diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 92dc71259e5..73ff5fe05b9 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -69,6 +69,13 @@ enum GatewaySettingsStore { account: self.preferredGatewayStableIDAccount) } + static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) { + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.preferredGatewayStableIDAccount) + defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey) + } + static func loadLastDiscoveredGatewayStableID() -> String? { if let value = KeychainStore.loadString( service: self.gatewayService, @@ -89,6 +96,13 @@ enum GatewaySettingsStore { account: self.lastDiscoveredGatewayStableIDAccount) } + static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) { + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.lastDiscoveredGatewayStableIDAccount) + defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) + } + static func loadGatewayToken(instanceId: String) -> String? { let account = self.gatewayTokenAccount(instanceId: instanceId) let token = KeychainStore.loadString(service: self.gatewayService, account: account)? @@ -119,6 +133,12 @@ enum GatewaySettingsStore { account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) } + static func clearGatewayBootstrapToken(instanceId: String) { + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) + } + static func loadGatewayPassword(instanceId: String) -> String? { KeychainStore.loadString( service: self.gatewayService, diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4c0ab81f1a1..998b0ef70c5 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1697,14 +1697,24 @@ extension NodeAppModel { password: password, nodeOptions: connectOptions) self.prepareForGatewayConnect(url: url, stableID: effectiveStableID) - self.startOperatorGatewayLoop( - url: url, - stableID: effectiveStableID, + if self.shouldStartOperatorGatewayLoop( token: token, bootstrapToken: bootstrapToken, password: password, - nodeOptions: connectOptions, - sessionBox: sessionBox) + stableID: effectiveStableID) + { + self.startOperatorGatewayLoop( + url: url, + stableID: effectiveStableID, + token: token, + bootstrapToken: bootstrapToken, + password: password, + nodeOptions: connectOptions, + sessionBox: sessionBox) + } else { + self.operatorGatewayTask = nil + Task { await self.operatorGateway.disconnect() } + } self.startNodeGatewayLoop( url: url, stableID: effectiveStableID, @@ -1785,6 +1795,86 @@ private extension NodeAppModel { self.apnsLastRegisteredTokenHex = nil } + func shouldStartOperatorGatewayLoop( + token: String?, + bootstrapToken: String?, + password: String?, + stableID _: String) -> Bool + { + Self.shouldStartOperatorGatewayLoop( + token: token, + bootstrapToken: bootstrapToken, + password: password, + hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator")) + } + + func hasStoredGatewayRoleToken(_ role: String) -> Bool { + let identity = DeviceIdentityStore.loadOrCreate() + return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil + } + + static func shouldStartOperatorGatewayLoop( + token: String?, + bootstrapToken: String?, + password: String?, + hasStoredOperatorToken: Bool) -> Bool + { + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedToken.isEmpty { + return true + } + let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedPassword.isEmpty { + return true + } + let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedBootstrapToken.isEmpty { + return false + } + return hasStoredOperatorToken + } + + static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? { + guard let config else { return nil } + let trimmedBootstrapToken = config.bootstrapToken? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmedBootstrapToken.isEmpty else { return config } + return GatewayConnectConfig( + url: config.url, + stableID: config.stableID, + tls: config.tls, + token: config.token, + bootstrapToken: nil, + password: config.password, + nodeOptions: config.nodeOptions) + } + + func currentGatewayReconnectAuth( + fallbackToken: String?, + fallbackBootstrapToken: String?, + fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?) + { + if let cfg = self.activeGatewayConnectConfig { + return (cfg.token, cfg.bootstrapToken, cfg.password) + } + return (fallbackToken, fallbackBootstrapToken, fallbackPassword) + } + + func clearPersistedGatewayBootstrapTokenIfNeeded() { + // Always drop the in-memory bootstrap token after the first successful + // bootstrap connect so reconnect loops cannot reuse a spent token. + self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig) + + let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmedInstanceId.isEmpty else { return } + guard + GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: trimmedInstanceId) != nil + else { return } + + GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId) + } + func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { guard self.isBackgrounded else { return } guard !self.backgroundReconnectSuppressed else { return } @@ -1841,11 +1931,15 @@ private extension NodeAppModel { displayName: nodeOptions.clientDisplayName) do { + let reconnectAuth = self.currentGatewayReconnectAuth( + fallbackToken: token, + fallbackBootstrapToken: bootstrapToken, + fallbackPassword: password) try await self.operatorGateway.connect( url: url, - token: token, - bootstrapToken: bootstrapToken, - password: password, + token: reconnectAuth.token, + bootstrapToken: reconnectAuth.bootstrapToken, + password: reconnectAuth.password, connectOptions: operatorOptions, sessionBox: sessionBox, onConnected: { [weak self] in @@ -1948,12 +2042,16 @@ private extension NodeAppModel { do { let epochMs = Int(Date().timeIntervalSince1970 * 1000) + let reconnectAuth = self.currentGatewayReconnectAuth( + fallbackToken: token, + fallbackBootstrapToken: bootstrapToken, + fallbackPassword: password) GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)") try await self.nodeGateway.connect( url: url, - token: token, - bootstrapToken: bootstrapToken, - password: password, + token: reconnectAuth.token, + bootstrapToken: reconnectAuth.bootstrapToken, + password: reconnectAuth.password, connectOptions: currentOptions, sessionBox: sessionBox, onConnected: { [weak self] in @@ -1965,6 +2063,30 @@ private extension NodeAppModel { self.screen.errorText = nil UserDefaults.standard.set(true, forKey: "gateway.autoconnect") } + let usedBootstrapToken = + reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && + reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty == false + if usedBootstrapToken { + await MainActor.run { + self.clearPersistedGatewayBootstrapTokenIfNeeded() + if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop( + token: reconnectAuth.token, + bootstrapToken: nil, + password: reconnectAuth.password, + stableID: stableID) + { + self.startOperatorGatewayLoop( + url: url, + stableID: stableID, + token: reconnectAuth.token, + bootstrapToken: nil, + password: reconnectAuth.password, + nodeOptions: currentOptions, + sessionBox: sessionBox) + } + } + } let relayData = await MainActor.run { ( sessionKey: self.mainSessionKey, @@ -1975,8 +2097,8 @@ private extension NodeAppModel { ShareGatewayRelaySettings.saveConfig( ShareGatewayRelayConfig( gatewayURLString: url.absoluteString, - token: token, - password: password, + token: reconnectAuth.token, + password: reconnectAuth.password, sessionKey: relayData.sessionKey, deliveryChannel: relayData.deliveryChannel, deliveryTo: relayData.deliveryTo)) @@ -3015,6 +3137,20 @@ extension NodeAppModel { static func _test_currentDeepLinkKey() -> String { self.expectedDeepLinkKey() } + + static func _test_shouldStartOperatorGatewayLoop( + token: String?, + bootstrapToken: String?, + password: String?, + hasStoredOperatorToken: Bool) -> Bool + { + self.shouldStartOperatorGatewayLoop( + token: token, + bootstrapToken: bootstrapToken, + password: password, + hasStoredOperatorToken: hasStoredOperatorToken) + } + } #endif // swiftlint:enable type_body_length file_length diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 6df8c1ec510..8d180e6b7f4 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -1008,6 +1008,11 @@ struct SettingsTab: View { // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). GatewaySettingsStore.clearLastGatewayConnection() + GatewaySettingsStore.clearPreferredGatewayStableID() + GatewaySettingsStore.clearLastDiscoveredGatewayStableID() + // Resetting onboarding should also forget trusted gateway TLS fingerprints. + // Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop. + GatewayTLSStore.clearAllFingerprints() OnboardingStateStore.reset() // RootCanvas also short-circuits onboarding when these are true. diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 06e11ec8437..1cddd7c73f6 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -5,6 +5,7 @@ import Testing @testable import OpenClaw @Suite(.serialized) struct GatewayConnectionSecurityTests { + @MainActor private func makeController() -> GatewayConnectionController { GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false) } @@ -32,8 +33,7 @@ import Testing } private func clearTLSFingerprint(stableID: String) { - let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard - suite.removeObject(forKey: "gateway.tls.\(stableID)") + GatewayTLSStore.clearFingerprint(stableID: stableID) } @Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async { @@ -126,4 +126,21 @@ import Testing #expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443) #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789) } + + @Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async { + let stableID1 = "test|\(UUID().uuidString)" + let stableID2 = "test|\(UUID().uuidString)" + defer { GatewayTLSStore.clearAllFingerprints() } + + GatewayTLSStore.saveFingerprint("11", stableID: stableID1) + GatewayTLSStore.saveFingerprint("22", stableID: stableID2) + + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == "11") + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == "22") + + GatewayTLSStore.clearAllFingerprints() + + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == nil) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil) + } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index d2ec7039ad7..bda4b7c1a0d 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -96,6 +96,64 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(appModel.mainSessionKey == "agent:agent-123:main") } + @Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() { + #expect( + !NodeAppModel._test_shouldStartOperatorGatewayLoop( + token: nil, + bootstrapToken: "fresh-bootstrap-token", + password: nil, + hasStoredOperatorToken: true) + ) + #expect( + !NodeAppModel._test_shouldStartOperatorGatewayLoop( + token: nil, + bootstrapToken: nil, + password: nil, + hasStoredOperatorToken: false) + ) + #expect( + NodeAppModel._test_shouldStartOperatorGatewayLoop( + token: nil, + bootstrapToken: nil, + password: nil, + hasStoredOperatorToken: true) + ) + #expect( + NodeAppModel._test_shouldStartOperatorGatewayLoop( + token: "shared-token", + bootstrapToken: "fresh-bootstrap-token", + password: nil, + hasStoredOperatorToken: false) + ) + } + + @Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() { + let config = GatewayConnectConfig( + url: URL(string: "wss://gateway.example")!, + stableID: "test-gateway", + tls: nil, + token: nil, + bootstrapToken: "spent-bootstrap-token", + password: nil, + nodeOptions: GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios", + clientMode: "node", + clientDisplayName: nil)) + + let cleared = NodeAppModel.clearingBootstrapToken(in: config) + #expect(cleared?.bootstrapToken == nil) + #expect(cleared?.url == config.url) + #expect(cleared?.stableID == config.stableID) + #expect(cleared?.token == config.token) + #expect(cleared?.password == config.password) + #expect(cleared?.nodeOptions.role == config.nodeOptions.role) + } + @Test @MainActor func handleInvokeRejectsBackgroundCommands() async { let appModel = NodeAppModel() appModel.setScenePhase(.background) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index fb3a89a2493..001c84e29c4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -35,6 +35,25 @@ public enum GatewayTLSStore { _ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID) } + @discardableResult + public static func clearFingerprint(stableID: String) -> Bool { + let removedKeychain = GenericPasswordKeychainStore.delete( + service: self.keychainService, + account: stableID) + self.clearLegacyFingerprint(stableID: stableID) + return removedKeychain + } + + @discardableResult + public static func clearAllFingerprints() -> Bool { + let removedKeychain = SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + ] as CFDictionary) + self.clearAllLegacyFingerprints() + return removedKeychain == errSecSuccess || removedKeychain == errSecItemNotFound + } + // MARK: - Migration /// On first Keychain read for a given stableID, move any legacy UserDefaults @@ -53,6 +72,18 @@ public enum GatewayTLSStore { } defaults.removeObject(forKey: legacyKey) } + + private static func clearLegacyFingerprint(stableID: String) { + guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return } + defaults.removeObject(forKey: self.legacyKeyPrefix + stableID) + } + + private static func clearAllLegacyFingerprints() { + guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return } + for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(self.legacyKeyPrefix) { + defaults.removeObject(forKey: key) + } + } } public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable { diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 9000b5f2105..9b257b58163 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -80,6 +80,10 @@ export function registerControlUiAndPairingSuite(): void { return device; }; + const REMOTE_BOOTSTRAP_HEADERS = { + "x-forwarded-for": "10.0.0.14", + }; + const expectStatusAndHealthOk = async (ws: WebSocket) => { const status = await rpcReq(ws, "status"); expect(status.ok).toBe(true); @@ -706,6 +710,215 @@ export function registerControlUiAndPairingSuite(): void { restoreGatewayToken(prevToken); }); + test("auto-approves fresh node bootstrap pairing from qr setup code", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-node-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const issued = await issueDeviceBootstrapToken(); + const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(true); + const initialPayload = initial.payload as + | { + type?: string; + auth?: { + deviceToken?: string; + role?: string; + scopes?: string[]; + }; + } + | undefined; + expect(initialPayload?.type).toBe("hello-ok"); + const issuedDeviceToken = initialPayload?.auth?.deviceToken; + expect(issuedDeviceToken).toBeDefined(); + expect(initialPayload?.auth?.role).toBe("node"); + expect(initialPayload?.auth?.scopes ?? []).toEqual([]); + + const afterBootstrap = await listDevicePairing(); + expect( + afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(expect.arrayContaining(["node"])); + expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); + if (!issuedDeviceToken) { + throw new Error("expected hello-ok auth.deviceToken for bootstrap onboarding"); + } + + wsBootstrap.close(); + + const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const replay = await connectReq(wsReplay, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(replay.ok).toBe(false); + expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, + ); + wsReplay.close(); + + const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const reconnect = await connectReq(wsReconnect, { + skipDefaultAuth: true, + deviceToken: issuedDeviceToken, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(reconnect.ok).toBe(true); + wsReconnect.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-role-upgrade-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const seededRequest = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + role: "operator", + scopes: ["operator.read"], + clientId: client.id, + clientMode: client.mode, + platform: client.platform, + deviceFamily: client.deviceFamily, + }); + await approveDevicePairing(seededRequest.request.requestId, { + callerScopes: ["operator.read"], + }); + + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["node"], + scopes: [], + }, + }); + const wsUpgrade = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const upgrade = await connectReq(wsUpgrade, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(upgrade.ok).toBe(false); + expect(upgrade.error?.message ?? "").toContain("pairing required"); + expect((upgrade.error?.details as { code?: string; reason?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + expect( + (upgrade.error?.details as { code?: string; reason?: string } | undefined)?.reason, + ).toBe("role-upgrade"); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.role).toBe("node"); + expect(pending[0]?.roles).toEqual(["node"]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(expect.arrayContaining(["operator"])); + wsUpgrade.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + + const { identityPath, identity, client } = await createOperatorIdentityFixture( + "openclaw-bootstrap-operator-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["operator"], + scopes: ["operator.read"], + }, + }); + const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: ["operator.read"], + client, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); + expect((initial.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.role).toBe("operator"); + expect(pending[0]?.scopes ?? []).toEqual(expect.arrayContaining(["operator.read"])); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); + wsBootstrap.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + test("merges remote node/operator pairing requests for the same unpaired device", async () => { const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 7351b3f42ed..288d9ec89f1 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -2,7 +2,10 @@ import type { IncomingMessage } from "node:http"; import os from "node:os"; import type { WebSocket } from "ws"; import { loadConfig } from "../../../config/config.js"; -import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js"; +import { + revokeDeviceBootstrapToken, + verifyDeviceBootstrapToken, +} from "../../../infra/device-bootstrap.js"; import { deriveDeviceIdFromPublicKey, normalizeDevicePublicKeyBase64Url, @@ -752,6 +755,7 @@ export function attachGatewayWsMessageHandler(params: { }; const requirePairing = async ( reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade", + existingPairedDevice: Awaited> | null = null, ) => { const pairingStateAllowsRequestedAccess = ( pairedCandidate: Awaited>, @@ -786,11 +790,23 @@ export function attachGatewayWsMessageHandler(params: { isWebchat, reason, }); + // QR bootstrap onboarding is node-only and single-use. When a fresh device presents + // a valid bootstrap token for the baseline node profile, complete pairing in the same + // handshake so iOS does not get stuck retrying with an already-consumed bootstrap token. + const allowSilentBootstrapPairing = + authMethod === "bootstrap-token" && + reason === "not-paired" && + role === "node" && + scopes.length === 0 && + !existingPairedDevice; const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, ...clientPairingMetadata, - silent: reason === "scope-upgrade" ? false : allowSilentLocalPairing, + silent: + reason === "scope-upgrade" + ? false + : allowSilentLocalPairing || allowSilentBootstrapPairing, }); const context = buildRequestContext(); let approved: Awaited> | undefined; @@ -815,6 +831,16 @@ export function attachGatewayWsMessageHandler(params: { callerScopes: scopes, }); if (approved?.status === "approved") { + if (allowSilentBootstrapPairing && bootstrapTokenCandidate) { + const revoked = await revokeDeviceBootstrapToken({ + token: bootstrapTokenCandidate, + }); + if (!revoked.removed) { + logGateway.warn( + `bootstrap token revoke skipped after silent auto-approval device=${approved.device.deviceId}`, + ); + } + } logGateway.info( `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, ); @@ -879,7 +905,7 @@ export function attachGatewayWsMessageHandler(params: { const paired = await getPairedDevice(device.id); const isPaired = paired?.publicKey === devicePublicKey; if (!isPaired) { - const ok = await requirePairing("not-paired"); + const ok = await requirePairing("not-paired", paired); if (!ok) { return; } @@ -899,7 +925,7 @@ export function attachGatewayWsMessageHandler(params: { logGateway.warn( `security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? ""} pinnedPlatform=${pairedPlatform ?? ""} claimedDeviceFamily=${claimedDeviceFamily ?? ""} pinnedDeviceFamily=${pairedDeviceFamily ?? ""} client=${connectParams.client.id} conn=${connId}`, ); - const ok = await requirePairing("metadata-upgrade"); + const ok = await requirePairing("metadata-upgrade", paired); if (!ok) { return; } @@ -920,13 +946,13 @@ export function attachGatewayWsMessageHandler(params: { const allowedRoles = new Set(pairedRoles); if (allowedRoles.size === 0) { logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes); - const ok = await requirePairing("role-upgrade"); + const ok = await requirePairing("role-upgrade", paired); if (!ok) { return; } } else if (!allowedRoles.has(role)) { logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes); - const ok = await requirePairing("role-upgrade"); + const ok = await requirePairing("role-upgrade", paired); if (!ok) { return; } @@ -935,7 +961,7 @@ export function attachGatewayWsMessageHandler(params: { if (scopes.length > 0) { if (pairedScopes.length === 0) { logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); - const ok = await requirePairing("scope-upgrade"); + const ok = await requirePairing("scope-upgrade", paired); if (!ok) { return; } @@ -947,7 +973,7 @@ export function attachGatewayWsMessageHandler(params: { }); if (!scopesAllowed) { logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); - const ok = await requirePairing("scope-upgrade"); + const ok = await requirePairing("scope-upgrade", paired); if (!ok) { return; } diff --git a/src/gateway/test-helpers.server.test.ts b/src/gateway/test-helpers.server.test.ts new file mode 100644 index 00000000000..b05b9ad5da0 --- /dev/null +++ b/src/gateway/test-helpers.server.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { testOnlyResolveAuthTokenForSignature } from "./test-helpers.server.js"; + +describe("testOnlyResolveAuthTokenForSignature", () => { + it("matches connect auth precedence for bootstrap tokens", () => { + expect( + testOnlyResolveAuthTokenForSignature({ + token: undefined, + bootstrapToken: "bootstrap-token", + deviceToken: "device-token", + }), + ).toBe("bootstrap-token"); + }); + + it("still prefers the shared token when present", () => { + expect( + testOnlyResolveAuthTokenForSignature({ + token: "shared-token", + bootstrapToken: "bootstrap-token", + deviceToken: "device-token", + }), + ).toBe("shared-token"); + }); +}); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index e96cebad507..1a1aec3b367 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -684,10 +684,27 @@ export async function readConnectChallengeNonce( } } +function resolveAuthTokenForSignature(opts?: { + token?: string; + bootstrapToken?: string; + deviceToken?: string; +}) { + return opts?.token ?? opts?.bootstrapToken ?? opts?.deviceToken; +} + +export function testOnlyResolveAuthTokenForSignature(opts?: { + token?: string; + bootstrapToken?: string; + deviceToken?: string; +}) { + return resolveAuthTokenForSignature(opts); +} + export async function connectReq( ws: WebSocket, opts?: { token?: string; + bootstrapToken?: string; deviceToken?: string; password?: string; skipDefaultAuth?: boolean; @@ -742,9 +759,14 @@ export async function connectReq( ? ((testState.gatewayAuth as { password?: string }).password ?? undefined) : process.env.OPENCLAW_GATEWAY_PASSWORD; const token = opts?.token ?? defaultToken; + const bootstrapToken = opts?.bootstrapToken?.trim() || undefined; const deviceToken = opts?.deviceToken?.trim() || undefined; const password = opts?.password ?? defaultPassword; - const authTokenForSignature = token ?? deviceToken; + const authTokenForSignature = resolveAuthTokenForSignature({ + token, + bootstrapToken, + deviceToken, + }); const requestedScopes = Array.isArray(opts?.scopes) ? opts.scopes : role === "operator" @@ -811,9 +833,10 @@ export async function connectReq( role, scopes: requestedScopes, auth: - token || password || deviceToken + token || bootstrapToken || password || deviceToken ? { token, + bootstrapToken, deviceToken, password, } diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index a4c01502920..9344c0db3a0 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -70,23 +70,27 @@ describe("device bootstrap tokens", () => { }); }); - it("verifies valid bootstrap tokens without consuming them before expiry", async () => { + it("verifies valid bootstrap tokens and binds them to the first device identity", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); - await expect( - verifyBootstrapToken(baseDir, issued.token, { - role: "operator", - scopes: ["operator.read", "operator.write", "operator.talk.secrets"], - }), - ).resolves.toEqual({ ok: true }); - await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ - ok: true, - }); + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); - expect(raw).toContain(issued.token); + const parsed = JSON.parse(raw) as Record< + string, + { + token: string; + deviceId?: string; + publicKey?: string; + } + >; + expect(parsed[issued.token]).toMatchObject({ + token: issued.token, + deviceId: "device-123", + publicKey: "public-key-123", + }); }); it("clears outstanding bootstrap tokens on demand", async () => { @@ -125,7 +129,7 @@ describe("device bootstrap tokens", () => { await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true }); }); - it("verifies bootstrap tokens by the persisted map key without deleting them", async () => { + it("verifies bootstrap tokens by the persisted map key and binds them", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); const issuedAtMs = Date.now(); @@ -153,7 +157,15 @@ describe("device bootstrap tokens", () => { await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); const raw = await fs.readFile(bootstrapPath, "utf8"); - expect(raw).toContain(issued.token); + const parsed = JSON.parse(raw) as Record< + string, + { token: string; deviceId?: string; publicKey?: string } + >; + expect(parsed["legacy-key"]).toMatchObject({ + token: issued.token, + deviceId: "device-123", + publicKey: "public-key-123", + }); }); it("keeps the token when required verification fields are blank", async () => { @@ -225,7 +237,7 @@ describe("device bootstrap tokens", () => { ).resolves.toEqual({ ok: true }); }); - it("accepts trimmed bootstrap tokens without consuming them", async () => { + it("accepts trimmed bootstrap tokens and binds them", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -234,7 +246,8 @@ describe("device bootstrap tokens", () => { }); const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); - expect(raw).toContain(issued.token); + const parsed = JSON.parse(raw) as Record; + expect(parsed[issued.token]?.deviceId).toBe("device-123"); }); it("rejects blank or unknown tokens", async () => { @@ -275,6 +288,19 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.token).toBe(issued.token); }); + it("rejects a second device identity after the first verification binds the token", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); + await expect( + verifyBootstrapToken(baseDir, issued.token, { + deviceId: "device-456", + publicKey: "public-key-456", + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + }); + it("fails closed for unbound legacy records and prunes expired tokens", async () => { vi.useFakeTimers(); const baseDir = await createTempDir(); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 846f23f5165..ede8daf9f8e 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -189,7 +189,7 @@ export async function verifyDeviceBootstrapToken(params: { if (!found) { return { ok: false, reason: "bootstrap_token_invalid" }; } - const [, record] = found; + const [tokenKey, record] = found; const deviceId = params.deviceId.trim(); const publicKey = params.publicKey.trim(); @@ -198,8 +198,8 @@ export async function verifyDeviceBootstrapToken(params: { return { ok: false, reason: "bootstrap_token_invalid" }; } const allowedProfile = resolvePersistedBootstrapProfile(record); - // Fail closed for unbound legacy setup codes and for any attempt to redeem - // the token outside the issued role/scope allowlist. + // Fail closed for any attempt to redeem the token outside the issued + // role/scope allowlist before binding it to a concrete device identity. if ( allowedProfile.roles.length === 0 || !bootstrapProfileAllowsRequest({ @@ -211,9 +211,31 @@ export async function verifyDeviceBootstrapToken(params: { return { ok: false, reason: "bootstrap_token_invalid" }; } - // Keep valid setup codes alive until they expire or are explicitly revoked. - // Approval happens after bootstrap verification, so consuming the token here - // makes post-approval reconnect impossible. + const boundDeviceId = record.deviceId?.trim(); + const boundPublicKey = record.publicKey?.trim(); + if (boundDeviceId || boundPublicKey) { + if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + state[tokenKey] = { + ...record, + profile: allowedProfile, + deviceId, + publicKey, + lastUsedAtMs: Date.now(), + }; + await persistState(state, params.baseDir); + return { ok: true }; + } + + state[tokenKey] = { + ...record, + profile: allowedProfile, + deviceId, + publicKey, + lastUsedAtMs: Date.now(), + }; + await persistState(state, params.baseDir); return { ok: true }; }); } diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index d79f464de7a..758c2308d98 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -34,6 +34,19 @@ async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) { await approveDevicePairing(request.request.requestId, { callerScopes: scopes }, baseDir); } +async function setupPairedNodeDevice(baseDir: string) { + const request = await requestDevicePairing( + { + deviceId: "node-1", + publicKey: "public-key-node-1", + role: "node", + scopes: [], + }, + baseDir, + ); + await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir); +} + async function setupOperatorToken(scopes: string[]) { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, scopes); @@ -55,7 +68,7 @@ function verifyOperatorToken(params: { baseDir: string; token: string; scopes: s function requireToken(token: string | undefined): string { expect(typeof token).toBe("string"); if (typeof token !== "string") { - throw new Error("expected operator token to be issued"); + throw new Error("expected device token to be issued"); } return token; } @@ -389,6 +402,35 @@ describe("device pairing tokens", () => { expect(after?.approvedScopes).toEqual(["operator.read"]); }); + test("preserves explicit empty scope baselines for node device tokens", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + await setupPairedNodeDevice(baseDir); + + const paired = await getPairedDevice("node-1", baseDir); + expect(paired?.scopes).toEqual([]); + expect(paired?.approvedScopes).toEqual([]); + + const seededToken = requireToken(paired?.tokens?.node?.token); + await expect( + ensureDeviceToken({ + deviceId: "node-1", + role: "node", + scopes: [], + baseDir, + }), + ).resolves.toEqual(expect.objectContaining({ token: seededToken, scopes: [] })); + + await expect( + verifyDeviceToken({ + deviceId: "node-1", + token: seededToken, + role: "node", + scopes: [], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + }); + test("verifies token and rejects mismatches", async () => { const { baseDir, token } = await setupOperatorToken(["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index bd7dfa849ef..9c905ccd6dd 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -188,10 +188,12 @@ export function hasEffectivePairedDeviceRole( function mergeScopes(...items: Array): string[] | undefined { const scopes = new Set(); + let sawExplicitScopeList = false; for (const item of items) { if (!item) { continue; } + sawExplicitScopeList = true; for (const scope of item) { const trimmed = scope.trim(); if (trimmed) { @@ -200,7 +202,7 @@ function mergeScopes(...items: Array): string[] | undefine } } if (scopes.size === 0) { - return undefined; + return sawExplicitScopeList ? [] : undefined; } return [...scopes]; }