fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)

* fix(pairing): restore qr bootstrap onboarding handoff

* fix(pairing): tighten bootstrap handoff follow-ups

* fix(pairing): migrate legacy gateway device auth

* fix(pairing): narrow qr bootstrap handoff scope

* fix(pairing): clear ios tls trust on onboarding reset

* fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
This commit is contained in:
Nimrod Gutman
2026-03-31 21:11:35 +03:00
committed by GitHub
parent 693d17c4a2
commit 69fe999373
15 changed files with 694 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ReturnType<typeof getPairedDevice>> | null = null,
) => {
const pairingStateAllowsRequestedAccess = (
pairedCandidate: Awaited<ReturnType<typeof getPairedDevice>>,
@@ -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<ReturnType<typeof approveDevicePairing>> | 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 ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} 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;
}

View File

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

View File

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

View File

@@ -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<string, { deviceId?: string }>;
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();

View File

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

View File

@@ -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"]);

View File

@@ -188,10 +188,12 @@ export function hasEffectivePairedDeviceRole(
function mergeScopes(...items: Array<string[] | undefined>): string[] | undefined {
const scopes = new Set<string>();
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[] | undefined>): string[] | undefine
}
}
if (scopes.size === 0) {
return undefined;
return sawExplicitScopeList ? [] : undefined;
}
return [...scopes];
}