Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 59 additions & 29 deletions Chowder/Chowder/Services/ChatService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import UIKit

protocol ChatServiceDelegate: AnyObject {
func chatServiceDidConnect()
Expand All @@ -22,6 +21,10 @@ final class ChatService: NSObject {
private let gatewayURL: String
private let token: String
private let sessionKey: String
private let clientId = "openclaw-ios"
private let clientMode = "ui"
private let role = "operator"
private let scopes = ["operator.read", "operator.write"]

private var webSocketTask: URLSessionWebSocketTask?
private var urlSession: URLSession?
Expand All @@ -30,9 +33,6 @@ final class ChatService: NSObject {
private var isReconnecting = false
private var hasSentConnectRequest = false

/// Stable device identifier persisted across launches (used for device pairing).
private let deviceId: String

/// Monotonically increasing request ID counter.
private var nextRequestId: Int = 1

Expand All @@ -50,22 +50,8 @@ final class ChatService: NSObject {
self.token = token
self.sessionKey = sessionKey

// Use identifierForVendor when available; fall back to a UUID persisted in UserDefaults.
if let vendorId = UIDevice.current.identifierForVendor?.uuidString {
self.deviceId = vendorId
} else {
let key = "com.chowder.deviceId"
if let stored = UserDefaults.standard.string(forKey: key) {
self.deviceId = stored
} else {
let generated = UUID().uuidString
UserDefaults.standard.set(generated, forKey: key)
self.deviceId = generated
}
}

super.init()
log("[INIT] gatewayURL=\(self.gatewayURL) sessionKey=\(self.sessionKey) tokenLength=\(token.count) deviceId=\(deviceId)")
log("[INIT] gatewayURL=\(self.gatewayURL) sessionKey=\(self.sessionKey) tokenLength=\(token.count)")
}

private func log(_ msg: String) {
Expand Down Expand Up @@ -324,11 +310,42 @@ final class ChatService: NSObject {
/// Send the `connect` request after receiving the gateway's challenge nonce.
/// Protocol: https://docs.openclaw.ai/gateway/protocol
private func sendConnectRequest(nonce: String) {
let authToken = DeviceIdentityService.loadDeviceToken() ?? token
let authSource = authToken == token ? "gatewayToken" : "deviceToken"

let identity: DeviceIdentity
do {
identity = try DeviceIdentityService.loadOrCreateIdentity()
} catch {
log("[AUTH] ❌ Failed to load/create device identity: \(error.localizedDescription)")
DispatchQueue.main.async { [weak self] in
self?.delegate?.chatServiceDidReceiveError(error)
}
return
}

let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let signature: DeviceSignature
do {
signature = try DeviceIdentityService.signConnectPayload(
identity: identity,
clientId: clientId,
clientMode: clientMode,
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
token: authToken,
nonce: nonce
)
} catch {
log("[AUTH] ❌ Failed to sign device payload: \(error.localizedDescription)")
DispatchQueue.main.async { [weak self] in
self?.delegate?.chatServiceDidReceiveError(error)
}
return
}

let requestId = makeRequestId()
// Valid client IDs: webchat-ui, openclaw-control-ui, webchat, cli,
// gateway-client, openclaw-macos, openclaw-ios, openclaw-android, node-host, test
// Valid client modes: webchat, cli, ui, backend, node, probe, test
// Device identity is schema-optional; omit until we implement keypair signing.
let frame: [String: Any] = [
"type": "req",
"id": requestId,
Expand All @@ -337,15 +354,22 @@ final class ChatService: NSObject {
"minProtocol": 3,
"maxProtocol": 3,
"client": [
"id": "openclaw-ios",
"id": clientId,
"version": "1.0.0",
"platform": "ios",
"mode": "ui"
"mode": clientMode
],
"role": "operator",
"scopes": ["operator.read", "operator.write"],
"role": role,
"scopes": scopes,
"auth": [
"token": token
"token": authToken
],
"device": [
"id": identity.id,
"publicKey": identity.publicKey,
"signature": signature.signature,
"signedAt": signature.signedAt,
"nonce": nonce
],
"locale": Locale.current.identifier,
"userAgent": "chowder-ios/1.0.0"
Expand All @@ -358,7 +382,7 @@ final class ChatService: NSObject {
return
}

log("[AUTH] Sending connect request: \(jsonString)")
log("[AUTH] Sending connect request id=\(requestId) auth=\(authSource) deviceId=\(identity.id.prefix(12))... signedAt=\(signedAtMs)")
webSocketTask?.send(.string(jsonString)) { [weak self] error in
if let error {
self?.log("[AUTH] ❌ Error sending connect: \(error.localizedDescription)")
Expand Down Expand Up @@ -605,6 +629,12 @@ final class ChatService: NSObject {
if payloadType == "hello-ok" {
let proto = payload?["protocol"] as? Int ?? 0
log("[AUTH] ✅ hello-ok — protocol=\(proto) id=\(id)")
if let auth = payload?["auth"] as? [String: Any],
let deviceToken = auth["deviceToken"] as? String,
!deviceToken.isEmpty {
DeviceIdentityService.saveDeviceToken(deviceToken)
log("[AUTH] Stored device token from hello-ok")
}

DispatchQueue.main.async { [weak self] in
self?.isConnected = true
Expand Down
109 changes: 109 additions & 0 deletions Chowder/Chowder/Services/DeviceIdentityService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import CryptoKit
import Foundation

struct DeviceIdentity {
let id: String
let publicKey: String
fileprivate let privateKey: Curve25519.Signing.PrivateKey
}

struct DeviceSignature {
let signature: String
let signedAt: Int64
}

enum DeviceIdentityError: LocalizedError {
case signingFailed

var errorDescription: String? {
switch self {
case .signingFailed:
return "Failed to sign device payload."
}
}
}

enum DeviceIdentityService {
private static let privateKeyKey = "gatewayDeviceEd25519PrivateKey"
private static let deviceTokenKey = "gatewayDeviceToken"

static func loadOrCreateIdentity() throws -> DeviceIdentity {
let privateKey: Curve25519.Signing.PrivateKey
if let storedData = KeychainService.loadData(key: privateKeyKey) {
do {
privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: storedData)
} catch {
let regenerated = Curve25519.Signing.PrivateKey()
KeychainService.save(key: privateKeyKey, data: regenerated.rawRepresentation)
privateKey = regenerated
}
} else {
privateKey = Curve25519.Signing.PrivateKey()
KeychainService.save(key: privateKeyKey, data: privateKey.rawRepresentation)
}

let publicKeyRaw = privateKey.publicKey.rawRepresentation
let id = CryptoHelper.sha256Hex(publicKeyRaw)
let publicKey = Base64URL.encode(publicKeyRaw)
return DeviceIdentity(id: id, publicKey: publicKey, privateKey: privateKey)
}

static func loadDeviceToken() -> String? {
KeychainService.load(key: deviceTokenKey)
}

static func saveDeviceToken(_ token: String) {
guard !token.isEmpty else { return }
KeychainService.save(key: deviceTokenKey, value: token)
}

static func signConnectPayload(
identity: DeviceIdentity,
clientId: String,
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int64,
token: String,
nonce: String
) throws -> DeviceSignature {
let scopesCSV = scopes.joined(separator: ",")
let payload = "v2|\(identity.id)|\(clientId)|\(clientMode)|\(role)|\(scopesCSV)|\(signedAtMs)|\(token)|\(nonce)"
let data = Data(payload.utf8)
do {
let signatureData = try identity.privateKey.signature(for: data)
return DeviceSignature(
signature: Base64URL.encode(signatureData),
signedAt: signedAtMs
)
} catch {
throw DeviceIdentityError.signingFailed
}
}
}

enum Base64URL {
static func encode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}

static func decode(_ string: String) -> Data? {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = base64.count % 4
if remainder != 0 {
base64 += String(repeating: "=", count: 4 - remainder)
}
return Data(base64Encoded: base64)
}
}

enum CryptoHelper {
static func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
}
18 changes: 14 additions & 4 deletions Chowder/Chowder/Services/KeychainService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ enum KeychainService {

static func save(key: String, value: String) {
guard let data = value.data(using: .utf8) else { return }
save(key: key, data: data)
}

static func save(key: String, data: Data) {

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
Expand All @@ -24,6 +28,14 @@ enum KeychainService {
}

static func load(key: String) -> String? {
guard let data = loadData(key: key),
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}

static func loadData(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
Expand All @@ -36,12 +48,10 @@ enum KeychainService {
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
let data = result as? Data else {
return nil
}

return string
return data
}

static func delete(key: String) {
Expand Down