diff --git a/Chowder/Chowder/Services/ChatService.swift b/Chowder/Chowder/Services/ChatService.swift index f8653f8..e43983d 100644 --- a/Chowder/Chowder/Services/ChatService.swift +++ b/Chowder/Chowder/Services/ChatService.swift @@ -1,5 +1,4 @@ import Foundation -import UIKit protocol ChatServiceDelegate: AnyObject { func chatServiceDidConnect() @@ -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? @@ -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 @@ -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) { @@ -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, @@ -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" @@ -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)") @@ -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 diff --git a/Chowder/Chowder/Services/DeviceIdentityService.swift b/Chowder/Chowder/Services/DeviceIdentityService.swift new file mode 100644 index 0000000..5aa8f62 --- /dev/null +++ b/Chowder/Chowder/Services/DeviceIdentityService.swift @@ -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() + } +} diff --git a/Chowder/Chowder/Services/KeychainService.swift b/Chowder/Chowder/Services/KeychainService.swift index 09a5877..444ecb8 100644 --- a/Chowder/Chowder/Services/KeychainService.swift +++ b/Chowder/Chowder/Services/KeychainService.swift @@ -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, @@ -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, @@ -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) {