fix(ios): harden gateway pairing setup

Harden iOS gateway setup-code pairing by rejecting non-loopback plaintext ws:// setup URLs before bootstrap token issuance, consolidating iOS setup parsing, and adding QR scan support from Settings.

Verification:
- pnpm test extensions/device-pair/index.test.ts
- swift test --package-path apps/shared/OpenClawKit --filter DeepLinksSecurityTests
- XcodeBuildMCP OpenClawLogicTests/DeepLinkParserTests
- targeted SwiftLint for touched iOS/OpenClawKit files
- pnpm exec oxfmt --check --threads=1 extensions/device-pair/index.ts extensions/device-pair/index.test.ts
- git diff --check origin/main...HEAD
- GitHub PR checks green on 58e5e60a5c
This commit is contained in:
Val Alexander
2026-05-04 02:11:47 -05:00
committed by GitHub
parent 5fe8cde28f
commit b2efd19648
15 changed files with 556 additions and 188 deletions

View File

@@ -1,5 +1,11 @@
# OpenClaw iOS Changelog
## 2026.5.4 - 2026-05-04
Maintenance update for the current OpenClaw development release.
- Gateway pairing now supports scanning QR codes from Settings and accepts full copied setup-code messages while keeping non-loopback `ws://` setup links blocked.
## 2026.5.3 - 2026-05-03
Maintenance update for the current OpenClaw development release.

View File

@@ -241,7 +241,7 @@ gateway can only send pushes for iOS devices that paired with that gateway.
## What Works Now (Concrete)
- Pairing via setup code flow (`/pair` then `/pair approve` in Telegram).
- Pairing via QR or setup code flow (`/pair qr` or `/pair`, then `/pair approve` in Telegram).
- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt.
- Chat + Talk surfaces through the operator gateway session.
- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.

View File

@@ -1,42 +0,0 @@
import Foundation
struct GatewaySetupPayload: Codable {
var url: String?
var host: String?
var port: Int?
var tls: Bool?
var bootstrapToken: String?
var token: String?
var password: String?
}
enum GatewaySetupCode {
static func decode(raw: String) -> GatewaySetupPayload? {
if let payload = decodeFromJSON(raw) {
return payload
}
if let decoded = decodeBase64Payload(raw),
let payload = decodeFromJSON(decoded)
{
return payload
}
return nil
}
private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data)
}
private static func decodeBase64Payload(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = trimmed
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = normalized.count % 4
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
guard let data = Data(base64Encoded: padded) else { return nil }
return String(data: data, encoding: .utf8)
}
}

View File

@@ -248,38 +248,23 @@ private struct ManualEntryStep: View {
return
}
guard let payload = GatewaySetupCode.decode(raw: raw) else {
self.setupStatusText = "Setup code not recognized."
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return
}
if let urlString = payload.url, let url = URL(string: urlString) {
self.applyURL(url)
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
if let port = payload.port {
self.manualPortText = String(port)
} else {
self.manualPortText = ""
}
if let tls = payload.tls {
self.manualUseTLS = tls
}
} else if let url = URL(string: raw), url.scheme != nil {
self.applyURL(url)
} else {
self.setupStatusText = "Setup code missing URL or host."
return
}
self.manualHost = link.host
self.manualPortText = String(link.port)
self.manualUseTLS = link.tls
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let token = link.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
} else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
} else if link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
self.manualToken = ""
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let password = link.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
} else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
} else if link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
self.manualPassword = ""
}
@@ -287,30 +272,12 @@ private struct ManualEntryStep: View {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedInstanceId.isEmpty {
let trimmedBootstrapToken =
payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId)
}
self.setupStatusText = "Setup code applied."
}
private func applyURL(_ url: URL) {
guard let host = url.host, !host.isEmpty else { return }
self.manualHost = host
if let port = url.port {
self.manualPortText = String(port)
} else {
self.manualPortText = ""
}
let scheme = (url.scheme ?? "").lowercased()
if scheme == "wss" || scheme == "https" {
self.manualUseTLS = true
} else if scheme == "ws" || scheme == "http" {
self.manualUseTLS = false
}
}
// (GatewaySetupCode) decode raw setup codes.
}
@MainActor

View File

@@ -203,14 +203,7 @@ struct OnboardingWizardView: View {
return
}
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
self.handleScannedLink(link)
return
}
if let url = URL(string: message),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
if let link = GatewayConnectDeepLink.fromSetupInput(message) {
self.handleScannedLink(link)
return
}

View File

@@ -65,20 +65,11 @@ struct QRScannerView: UIViewControllerRepresentable {
let payload = barcode.payloadStringValue
else { continue }
// Try setup code format first (base64url JSON from /pair qr).
if let link = GatewayConnectDeepLink.fromSetupCode(payload) {
if let link = GatewayConnectDeepLink.fromSetupInput(payload) {
self.handled = true
self.parent.onGatewayLink(link)
return
}
// Fall back to deep link URL format (openclaw://gateway?...).
if let url = URL(string: payload),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
self.handled = true
self.parent.onGatewayLink(link)
Task { @MainActor in
self.parent.onGatewayLink(link)
}
return
}
}

View File

@@ -49,6 +49,8 @@ struct SettingsTab: View {
@State private var defaultShareInstruction: String = ""
@AppStorage("gateway.setupCode") private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var manualGatewayPortText: String = ""
@State private var gatewayExpanded: Bool = true
@State private var selectedAgentPickerId: String = ""
@@ -98,6 +100,13 @@ struct SettingsTab: View {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
self.openGatewayQRScanner()
} label: {
Label("Scan QR Code", systemImage: "qrcode.viewfinder")
}
.disabled(self.connectingGatewayID != nil)
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
@@ -430,6 +439,30 @@ struct SettingsTab: View {
})
}
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onError: { error in
self.showQRScanner = false
self.setupStatusText = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
}
}
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
@@ -446,6 +479,14 @@ struct SettingsTab: View {
message: Text(help.message),
dismissButton: .default(Text("OK")))
}
.alert("QR Scanner Unavailable", isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
.onAppear {
self.lastLocationModeRaw = self.locationEnabledModeRaw
self.syncManualPortText()
@@ -769,39 +810,28 @@ struct SettingsTab: View {
return false
}
guard let payload = GatewaySetupCode.decode(raw: raw) else {
self.setupStatusText = "Setup code not recognized."
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return false
}
if let urlString = payload.url, let url = URL(string: urlString) {
self.applySetupURL(url)
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualGatewayHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
if let port = payload.port {
self.manualGatewayPort = port
self.manualGatewayPortText = String(port)
} else {
self.manualGatewayPort = 0
self.manualGatewayPortText = ""
}
if let tls = payload.tls {
self.manualGatewayTLS = tls
}
} else if let url = URL(string: raw), url.scheme != nil {
self.applySetupURL(url)
} else {
self.setupStatusText = "Setup code missing URL or host."
return false
}
self.applyGatewayLink(link)
return true
}
private func applyGatewayLink(_ link: GatewayConnectDeepLink) {
self.manualGatewayHost = link.host
self.manualGatewayPort = link.port
self.manualGatewayPortText = String(link.port)
self.manualGatewayTLS = link.tls
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedBootstrapToken =
payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
link.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 = link.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayToken = trimmedToken
if !trimmedInstanceId.isEmpty {
@@ -813,7 +843,7 @@ struct SettingsTab: View {
GatewaySettingsStore.saveGatewayToken("", instanceId: trimmedInstanceId)
}
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let password = link.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayPassword = trimmedPassword
if !trimmedInstanceId.isEmpty {
@@ -825,26 +855,33 @@ struct SettingsTab: View {
GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId)
}
}
return true
}
private func applySetupURL(_ url: URL) {
guard let host = url.host, !host.isEmpty else { return }
self.manualGatewayHost = host
if let port = url.port {
self.manualGatewayPort = port
self.manualGatewayPortText = String(port)
} else {
self.manualGatewayPort = 0
self.manualGatewayPortText = ""
}
let scheme = (url.scheme ?? "").lowercased()
if scheme == "wss" || scheme == "https" {
self.manualGatewayTLS = true
} else if scheme == "ws" || scheme == "http" {
self.manualGatewayTLS = false
private func openGatewayQRScanner() {
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.setupStatusText = "Opening QR scanner…"
self.showQRScanner = true
}
private func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
self.showQRScanner = false
self.setupCode = ""
self.applyGatewayLink(link)
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)"
Task { await self.connectAfterScannedGatewayLink() }
}
private func connectAfterScannedGatewayLink() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedPort = self.resolvedManualPort(host: host)
guard let port = resolvedPort else {
self.setupStatusText = "Failed: invalid port"
return
}
let ok = await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS)
guard ok else { return }
await self.connectManual()
}
private func resolvedManualPort(host: String) -> Int? {
@@ -892,8 +929,6 @@ struct SettingsTab: View {
queueLabel: "gateway.preflight")
}
// (GatewaySetupCode) decode raw setup codes.
private func connectManual() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {

View File

@@ -21,7 +21,6 @@ Sources/Gateway/GatewayProblemView.swift
Sources/Gateway/GatewayQuickSetupSheet.swift
Sources/Gateway/GatewayServiceResolver.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/GatewaySetupCode.swift
Sources/Gateway/GatewayTrustPromptAlert.swift
Sources/Gateway/KeychainStore.swift
Sources/Gateway/TCPProbe.swift

View File

@@ -161,4 +161,34 @@ private func agentAction(
token: nil,
password: nil))
}
@Test func parseGatewaySetupInputParsesFullCopiedSetupMessage() {
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupInput("""
Pairing setup code generated.
Setup code:
\(setupCode(from: payload))
""")
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func parseGatewaySetupInputParsesRawGatewayURL() {
let link = GatewayConnectDeepLink.fromSetupInput("wss://gateway.example.com:444")
#expect(link == .init(
host: "gateway.example.com",
port: 444,
tls: true,
bootstrapToken: nil,
token: nil,
password: nil))
}
}