diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 731d32e53..1a2c5e663 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -78,6 +78,7 @@ let package = Package( .executableTarget( name: "ClawdbotWizardCLI", dependencies: [ + .product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotProtocol", package: "ClawdbotKit"), ], path: "Sources/ClawdbotWizardCLI", diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index fb11d9430..3b5b96258 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -20,7 +20,7 @@ public struct ConnectParams: Codable, Sendable { public let permissions: [String: AnyCodable]? public let role: String? public let scopes: [String]? - public let device: [String: AnyCodable] + public let device: [String: AnyCodable]? public let auth: [String: AnyCodable]? public let locale: String? public let useragent: String? @@ -34,7 +34,7 @@ public struct ConnectParams: Codable, Sendable { permissions: [String: AnyCodable]?, role: String?, scopes: [String]?, - device: [String: AnyCodable], + device: [String: AnyCodable]?, auth: [String: AnyCodable]?, locale: String?, useragent: String? diff --git a/apps/macos/Sources/ClawdbotWizardCLI/main.swift b/apps/macos/Sources/ClawdbotWizardCLI/main.swift index b94053f65..81570946f 100644 --- a/apps/macos/Sources/ClawdbotWizardCLI/main.swift +++ b/apps/macos/Sources/ClawdbotWizardCLI/main.swift @@ -1,7 +1,10 @@ +import ClawdbotKit import ClawdbotProtocol import Darwin import Foundation +private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable + struct WizardCliOptions { var url: String? var token: String? @@ -228,6 +231,10 @@ private func parseInt(_ value: Any?) -> Int? { } actor GatewayWizardClient { + private enum ConnectChallengeError: Error { + case timeout + } + private let url: URL private let token: String? private let password: String? @@ -235,6 +242,7 @@ actor GatewayWizardClient { private let encoder = JSONEncoder() private let decoder = JSONDecoder() private let session = URLSession(configuration: .default) + private let connectChallengeTimeoutSeconds: Double = 0.75 private var task: URLSessionWebSocketTask? init(url: URL, token: String?, password: String?, json: Bool) { @@ -257,7 +265,7 @@ actor GatewayWizardClient { self.task = nil } - func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame { + func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame { guard let task = self.task else { throw WizardCliError.gatewayError("gateway not connected") } @@ -266,7 +274,7 @@ actor GatewayWizardClient { type: "req", id: id, method: method, - params: params.map { AnyCodable($0) }) + params: params.map { ProtoAnyCodable($0) }) let data = try self.encoder.encode(frame) try await task.send(.data(data)) @@ -309,28 +317,65 @@ actor GatewayWizardClient { } let osVersion = ProcessInfo.processInfo.operatingSystemVersion let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" - let client: [String: AnyCodable] = [ - "id": AnyCodable("clawdbot-macos"), - "displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"), - "version": AnyCodable("dev"), - "platform": AnyCodable(platform), - "deviceFamily": AnyCodable("Mac"), - "mode": AnyCodable("ui"), - "instanceId": AnyCodable(UUID().uuidString), + let clientId = "clawdbot-macos" + let clientMode = "ui" + let role = "operator" + let scopes: [String] = [] + let client: [String: ProtoAnyCodable] = [ + "id": ProtoAnyCodable(clientId), + "displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"), + "version": ProtoAnyCodable("dev"), + "platform": ProtoAnyCodable(platform), + "deviceFamily": ProtoAnyCodable("Mac"), + "mode": ProtoAnyCodable(clientMode), + "instanceId": ProtoAnyCodable(UUID().uuidString), ] - var params: [String: AnyCodable] = [ - "minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION), - "maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION), - "client": AnyCodable(client), - "caps": AnyCodable([String]()), - "locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier), - "userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), + var params: [String: ProtoAnyCodable] = [ + "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "client": ProtoAnyCodable(client), + "caps": ProtoAnyCodable([String]()), + "locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier), + "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), + "role": ProtoAnyCodable(role), + "scopes": ProtoAnyCodable(scopes), ] if let token = self.token { - params["auth"] = AnyCodable(["token": AnyCodable(token)]) + params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) } else if let password = self.password { - params["auth"] = AnyCodable(["password": AnyCodable(password)]) + params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) + } + let connectNonce = try await self.waitForConnectChallenge() + let identity = DeviceIdentityStore.loadOrCreate() + let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let scopesValue = scopes.joined(separator: ",") + var payloadParts = [ + connectNonce == nil ? "v1" : "v2", + identity.deviceId, + clientId, + clientMode, + role, + scopesValue, + String(signedAtMs), + self.token ?? "", + ] + if let connectNonce { + payloadParts.append(connectNonce) + } + let payload = payloadParts.joined(separator: "|") + if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), + let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { + var device: [String: ProtoAnyCodable] = [ + "id": ProtoAnyCodable(identity.deviceId), + "publicKey": ProtoAnyCodable(publicKey), + "signature": ProtoAnyCodable(signature), + "signedAt": ProtoAnyCodable(signedAtMs), + ] + if let connectNonce { + device["nonce"] = ProtoAnyCodable(connectNonce) + } + params["device"] = ProtoAnyCodable(device) } let reqId = UUID().uuidString @@ -338,31 +383,57 @@ actor GatewayWizardClient { type: "req", id: reqId, method: "connect", - params: AnyCodable(params)) + params: ProtoAnyCodable(params)) let data = try self.encoder.encode(frame) try await task.send(.data(data)) - let message = try await task.receive() - let frameResponse = try decodeFrame(message) - guard case let .res(res) = frameResponse, res.id == reqId else { - throw WizardCliError.gatewayError("connect failed (unexpected response)") + while true { + let message = try await task.receive() + let frameResponse = try decodeFrame(message) + if case let .res(res) = frameResponse, res.id == reqId { + if res.ok == false { + let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" + throw WizardCliError.gatewayError(msg) + } + _ = try self.decodePayload(res, as: HelloOk.self) + return + } } - if res.ok == false { - let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" - throw WizardCliError.gatewayError(msg) + } + + private func waitForConnectChallenge() async throws -> String? { + guard let task = self.task else { return nil } + do { + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { + while true { + let message = try await task.receive() + let frame = try decodeFrame(message) + if case let .event(evt) = frame, evt.event == "connect.challenge" { + if let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String { + return nonce + } + } + } + }) + } catch { + if error is ConnectChallengeError { return nil } + throw error } - _ = try self.decodePayload(res, as: HelloOk.self) } } private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws { - var params: [String: AnyCodable] = [:] + var params: [String: ProtoAnyCodable] = [:] let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if mode == "local" || mode == "remote" { - params["mode"] = AnyCodable(mode) + params["mode"] = ProtoAnyCodable(mode) } if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty { - params["workspace"] = AnyCodable(workspace) + params["workspace"] = ProtoAnyCodable(workspace) } let startResponse = try await client.request(method: "wizard.start", params: params) @@ -395,17 +466,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn if let step = decodeWizardStep(nextResult.step) { let answer = try promptAnswer(for: step) - var answerPayload: [String: AnyCodable] = [ - "stepId": AnyCodable(step.id), + var answerPayload: [String: ProtoAnyCodable] = [ + "stepId": ProtoAnyCodable(step.id), ] if !(answer is NSNull) { - answerPayload["value"] = AnyCodable(answer) + answerPayload["value"] = ProtoAnyCodable(answer) } let response = try await client.request( method: "wizard.next", params: [ - "sessionId": AnyCodable(sessionId), - "answer": AnyCodable(answerPayload), + "sessionId": ProtoAnyCodable(sessionId), + "answer": ProtoAnyCodable(answerPayload), ]) nextResult = try await client.decodePayload(response, as: WizardNextResult.self) if opts.json { @@ -414,7 +485,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn } else { let response = try await client.request( method: "wizard.next", - params: ["sessionId": AnyCodable(sessionId)]) + params: ["sessionId": ProtoAnyCodable(sessionId)]) nextResult = try await client.decodePayload(response, as: WizardNextResult.self) if opts.json { dumpResult(response) @@ -424,7 +495,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn } catch WizardCliError.cancelled { _ = try? await client.request( method: "wizard.cancel", - params: ["sessionId": AnyCodable(sessionId)]) + params: ["sessionId": ProtoAnyCodable(sessionId)]) throw WizardCliError.cancelled } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift index 57bf98bfd..3a3244614 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift @@ -1,11 +1,18 @@ import CryptoKit import Foundation -struct DeviceIdentity: Codable, Sendable { - var deviceId: String - var publicKey: String - var privateKey: String - var createdAtMs: Int +public struct DeviceIdentity: Codable, Sendable { + public var deviceId: String + public var publicKey: String + public var privateKey: String + public var createdAtMs: Int + + public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) { + self.deviceId = deviceId + self.publicKey = publicKey + self.privateKey = privateKey + self.createdAtMs = createdAtMs + } } enum DeviceIdentityPaths { @@ -27,10 +34,10 @@ enum DeviceIdentityPaths { } } -enum DeviceIdentityStore { +public enum DeviceIdentityStore { private static let fileName = "device.json" - static func loadOrCreate() -> DeviceIdentity { + public static func loadOrCreate() -> DeviceIdentity { let url = self.fileURL() if let data = try? Data(contentsOf: url), let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data), @@ -44,7 +51,7 @@ enum DeviceIdentityStore { return identity } - static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? { + public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? { guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil } do { let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData) @@ -76,7 +83,7 @@ enum DeviceIdentityStore { .replacingOccurrences(of: "=", with: "") } - static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? { + public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? { guard let data = Data(base64Encoded: identity.publicKey) else { return nil } return self.base64UrlEncode(data) } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift index 9480c3f60..abb888a49 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift @@ -94,6 +94,10 @@ public struct GatewayConnectOptions: Sendable { // Avoid ambiguity with the app's own AnyCodable type. private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable +private enum ConnectChallengeError: Error { + case timeout +} + public actor GatewayChannelActor { private let logger = Logger(subsystem: "com.clawdbot", category: "gateway") private var task: WebSocketTaskBox? @@ -113,6 +117,7 @@ public actor GatewayChannelActor { private let decoder = JSONDecoder() private let encoder = JSONEncoder() private let connectTimeoutSeconds: Double = 6 + private let connectChallengeTimeoutSeconds: Double = 0.75 private var watchdogTask: Task? private var tickTask: Task? private let defaultRequestTimeoutMs: Double = 15000 @@ -294,9 +299,10 @@ public actor GatewayChannelActor { } let identity = DeviceIdentityStore.loadOrCreate() let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let connectNonce = try await self.waitForConnectChallenge() let scopes = options.scopes.joined(separator: ",") - let payload = [ - "v1", + var payloadParts = [ + connectNonce == nil ? "v1" : "v2", identity.deviceId, clientId, clientMode, @@ -304,15 +310,23 @@ public actor GatewayChannelActor { scopes, String(signedAtMs), self.token ?? "", - ].joined(separator: "|") + ] + if let connectNonce { + payloadParts.append(connectNonce) + } + let payload = payloadParts.joined(separator: "|") if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { - params["device"] = ProtoAnyCodable([ + var device: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(identity.deviceId), "publicKey": ProtoAnyCodable(publicKey), "signature": ProtoAnyCodable(signature), "signedAt": ProtoAnyCodable(signedAtMs), - ]) + ] + if let connectNonce { + device["nonce"] = ProtoAnyCodable(connectNonce) + } + params["device"] = ProtoAnyCodable(device) } let frame = RequestFrame( @@ -322,40 +336,11 @@ public actor GatewayChannelActor { params: ProtoAnyCodable(params)) let data = try self.encoder.encode(frame) try await self.task?.send(.data(data)) - guard let msg = try await task?.receive() else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"]) - } - try await self.handleConnectResponse(msg, reqId: reqId) + let response = try await self.waitForConnectResponse(reqId: reqId) + try await self.handleConnectResponse(response) } - private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws { - let data: Data? = switch msg { - case let .data(d): d - case let .string(s): s.data(using: .utf8) - @unknown default: nil - } - guard let data else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"]) - } - let decoder = JSONDecoder() - guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"]) - } - guard case let .res(res) = frame, res.id == reqId else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"]) - } + private func handleConnectResponse(_ res: ResponseFrame) async throws { if res.ok == false { let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg]) @@ -424,6 +409,7 @@ public actor GatewayChannelActor { waiter.resume(returning: .res(res)) } case let .event(evt): + if evt.event == "connect.challenge" { return } if let seq = evt.seq { if let last = lastSeq, seq > last + 1 { await self.pushHandler?(.seqGap(expected: last + 1, received: seq)) @@ -437,6 +423,63 @@ public actor GatewayChannelActor { } } + private func waitForConnectChallenge() async throws -> String? { + guard let task = self.task else { return nil } + do { + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { [weak self] in + guard let self else { return nil } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } + if case let .event(evt) = frame, evt.event == "connect.challenge" { + if let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String { + return nonce + } + } + } + }) + } catch { + if error is ConnectChallengeError { return nil } + throw error + } + } + + private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { + guard let task = self.task else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"]) + } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"]) + } + if case let .res(res) = frame, res.id == reqId { + return res + } + } + } + + private func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? { + let data: Data? = switch msg { + case let .data(data): data + case let .string(text): text.data(using: .utf8) + @unknown default: nil + } + return data + } + private func watchTicks() async { let tolerance = self.tickIntervalMs * 2 while self.connected { diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift index 086c198d9..32c424f99 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift @@ -5,6 +5,7 @@ public let GATEWAY_PROTOCOL_VERSION = 3 public enum ErrorCode: String, Codable, Sendable { case notLinked = "NOT_LINKED" + case notPaired = "NOT_PAIRED" case agentTimeout = "AGENT_TIMEOUT" case invalidRequest = "INVALID_REQUEST" case unavailable = "UNAVAILABLE" @@ -15,6 +16,11 @@ public struct ConnectParams: Codable, Sendable { public let maxprotocol: Int public let client: [String: AnyCodable] public let caps: [String]? + public let commands: [String]? + public let permissions: [String: AnyCodable]? + public let role: String? + public let scopes: [String]? + public let device: [String: AnyCodable]? public let auth: [String: AnyCodable]? public let locale: String? public let useragent: String? @@ -24,6 +30,11 @@ public struct ConnectParams: Codable, Sendable { maxprotocol: Int, client: [String: AnyCodable], caps: [String]?, + commands: [String]?, + permissions: [String: AnyCodable]?, + role: String?, + scopes: [String]?, + device: [String: AnyCodable]?, auth: [String: AnyCodable]?, locale: String?, useragent: String? @@ -32,6 +43,11 @@ public struct ConnectParams: Codable, Sendable { self.maxprotocol = maxprotocol self.client = client self.caps = caps + self.commands = commands + self.permissions = permissions + self.role = role + self.scopes = scopes + self.device = device self.auth = auth self.locale = locale self.useragent = useragent @@ -41,6 +57,11 @@ public struct ConnectParams: Codable, Sendable { case maxprotocol = "maxProtocol" case client case caps + case commands + case permissions + case role + case scopes + case device case auth case locale case useragent = "userAgent" @@ -54,6 +75,7 @@ public struct HelloOk: Codable, Sendable { public let features: [String: AnyCodable] public let snapshot: Snapshot public let canvashosturl: String? + public let auth: [String: AnyCodable]? public let policy: [String: AnyCodable] public init( @@ -63,6 +85,7 @@ public struct HelloOk: Codable, Sendable { features: [String: AnyCodable], snapshot: Snapshot, canvashosturl: String?, + auth: [String: AnyCodable]?, policy: [String: AnyCodable] ) { self.type = type @@ -71,6 +94,7 @@ public struct HelloOk: Codable, Sendable { self.features = features self.snapshot = snapshot self.canvashosturl = canvashosturl + self.auth = auth self.policy = policy } private enum CodingKeys: String, CodingKey { @@ -80,6 +104,7 @@ public struct HelloOk: Codable, Sendable { case features case snapshot case canvashosturl = "canvasHostUrl" + case auth case policy } } @@ -706,6 +731,93 @@ public struct NodeInvokeParams: Codable, Sendable { } } +public struct NodeInvokeResultParams: Codable, Sendable { + public let id: String + public let nodeid: String + public let ok: Bool + public let payload: AnyCodable? + public let payloadjson: String? + public let error: [String: AnyCodable]? + + public init( + id: String, + nodeid: String, + ok: Bool, + payload: AnyCodable?, + payloadjson: String?, + error: [String: AnyCodable]? + ) { + self.id = id + self.nodeid = nodeid + self.ok = ok + self.payload = payload + self.payloadjson = payloadjson + self.error = error + } + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case ok + case payload + case payloadjson = "payloadJSON" + case error + } +} + +public struct NodeEventParams: Codable, Sendable { + public let event: String + public let payload: AnyCodable? + public let payloadjson: String? + + public init( + event: String, + payload: AnyCodable?, + payloadjson: String? + ) { + self.event = event + self.payload = payload + self.payloadjson = payloadjson + } + private enum CodingKeys: String, CodingKey { + case event + case payload + case payloadjson = "payloadJSON" + } +} + +public struct NodeInvokeRequestEvent: Codable, Sendable { + public let id: String + public let nodeid: String + public let command: String + public let paramsjson: String? + public let timeoutms: Int? + public let idempotencykey: String? + + public init( + id: String, + nodeid: String, + command: String, + paramsjson: String?, + timeoutms: Int?, + idempotencykey: String? + ) { + self.id = id + self.nodeid = nodeid + self.command = command + self.paramsjson = paramsjson + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case command + case paramsjson = "paramsJSON" + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + public struct SessionsListParams: Codable, Sendable { public let limit: Int? public let activeminutes: Int? @@ -1381,6 +1493,22 @@ public struct ModelsListResult: Codable, Sendable { public struct SkillsStatusParams: Codable, Sendable { } +public struct SkillsBinsParams: Codable, Sendable { +} + +public struct SkillsBinsResult: Codable, Sendable { + public let bins: [String] + + public init( + bins: [String] + ) { + self.bins = bins + } + private enum CodingKeys: String, CodingKey { + case bins + } +} + public struct SkillsInstallParams: Codable, Sendable { public let name: String public let installid: String @@ -1735,6 +1863,225 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { } } +public struct ExecApprovalRequestParams: Codable, Sendable { + public let command: String + public let cwd: String? + public let host: String? + public let security: String? + public let ask: String? + public let agentid: String? + public let resolvedpath: String? + public let sessionkey: String? + public let timeoutms: Int? + + public init( + command: String, + cwd: String?, + host: String?, + security: String?, + ask: String?, + agentid: String?, + resolvedpath: String?, + sessionkey: String?, + timeoutms: Int? + ) { + self.command = command + self.cwd = cwd + self.host = host + self.security = security + self.ask = ask + self.agentid = agentid + self.resolvedpath = resolvedpath + self.sessionkey = sessionkey + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case command + case cwd + case host + case security + case ask + case agentid = "agentId" + case resolvedpath = "resolvedPath" + case sessionkey = "sessionKey" + case timeoutms = "timeoutMs" + } +} + +public struct ExecApprovalResolveParams: Codable, Sendable { + public let id: String + public let decision: String + + public init( + id: String, + decision: String + ) { + self.id = id + self.decision = decision + } + private enum CodingKeys: String, CodingKey { + case id + case decision + } +} + +public struct DevicePairListParams: Codable, Sendable { +} + +public struct DevicePairApproveParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DevicePairRejectParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DeviceTokenRotateParams: Codable, Sendable { + public let deviceid: String + public let role: String + public let scopes: [String]? + + public init( + deviceid: String, + role: String, + scopes: [String]? + ) { + self.deviceid = deviceid + self.role = role + self.scopes = scopes + } + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + case scopes + } +} + +public struct DeviceTokenRevokeParams: Codable, Sendable { + public let deviceid: String + public let role: String + + public init( + deviceid: String, + role: String + ) { + self.deviceid = deviceid + self.role = role + } + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + } +} + +public struct DevicePairRequestedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let publickey: String + public let displayname: String? + public let platform: String? + public let clientid: String? + public let clientmode: String? + public let role: String? + public let roles: [String]? + public let scopes: [String]? + public let remoteip: String? + public let silent: Bool? + public let isrepair: Bool? + public let ts: Int + + public init( + requestid: String, + deviceid: String, + publickey: String, + displayname: String?, + platform: String?, + clientid: String?, + clientmode: String?, + role: String?, + roles: [String]?, + scopes: [String]?, + remoteip: String?, + silent: Bool?, + isrepair: Bool?, + ts: Int + ) { + self.requestid = requestid + self.deviceid = deviceid + self.publickey = publickey + self.displayname = displayname + self.platform = platform + self.clientid = clientid + self.clientmode = clientmode + self.role = role + self.roles = roles + self.scopes = scopes + self.remoteip = remoteip + self.silent = silent + self.isrepair = isrepair + self.ts = ts + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case publickey = "publicKey" + case displayname = "displayName" + case platform + case clientid = "clientId" + case clientmode = "clientMode" + case role + case roles + case scopes + case remoteip = "remoteIp" + case silent + case isrepair = "isRepair" + case ts + } +} + +public struct DevicePairResolvedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let decision: String + public let ts: Int + + public init( + requestid: String, + deviceid: String, + decision: String, + ts: Int + ) { + self.requestid = requestid + self.deviceid = deviceid + self.decision = decision + self.ts = ts + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case decision + case ts + } +} + public struct ChatHistoryParams: Codable, Sendable { public let sessionkey: String public let limit: Int? diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index ed6095c9c..cdd6ee5a7 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -20,6 +20,16 @@ handshake time. ## Handshake (connect) +Gateway → Client (pre-connect challenge): + +```json +{ + "type": "event", + "event": "connect.challenge", + "payload": { "nonce": "…", "ts": 1737264000000 } +} +``` + Client → Gateway: ```json @@ -43,7 +53,14 @@ Client → Gateway: "permissions": {}, "auth": { "token": "…" }, "locale": "en-US", - "userAgent": "clawdbot-cli/1.2.3" + "userAgent": "clawdbot-cli/1.2.3", + "device": { + "id": "device_fingerprint", + "publicKey": "…", + "signature": "…", + "signedAt": 1737264000000, + "nonce": "…" + } } } ``` @@ -99,7 +116,8 @@ When a device token is issued, `hello-ok` also includes: "id": "device_fingerprint", "publicKey": "…", "signature": "…", - "signedAt": 1737264000000 + "signedAt": 1737264000000, + "nonce": "…" } } } @@ -167,6 +185,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - Pairing approvals are required for new device IDs unless local auto-approval is enabled. - All WS clients must include `device` identity during `connect` (operator + node). +- Non-local connections must sign the server-provided `connect.challenge` nonce. ## TLS + pinning diff --git a/docs/refactor/clawnet.md b/docs/refactor/clawnet.md index ac7eec571..10692318c 100644 --- a/docs/refactor/clawnet.md +++ b/docs/refactor/clawnet.md @@ -288,6 +288,26 @@ Same `deviceId` across roles → single “Instance” row: --- +# Execution checklist (ship order) +- [x] **Device‑bound auth (PoP):** nonce challenge + signature verify on connect; remove bearer‑only for non‑local. +- [ ] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries. +- [ ] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests. +- [ ] **Approvals routing:** gateway‑hosted approvals; operator UI prompt/resolve; node stops prompting. +- [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation. +- [ ] **Discovery + allowlist:** WS discovery TXT includes TLS fingerprint + role hints; node commands filtered by server allowlist. +- [ ] **Presence unification:** dedupe deviceId across roles; include role/scope metadata; “single instance row”. +- [ ] **Docs + examples:** protocol doc, CLI docs, onboarding + security notes; no personal hostnames. +- [ ] **Test coverage:** connect auth paths, rotation/revoke, approvals, TLS fingerprint mismatch, presence. + +Process per item: +- Do implementation. +- Fresh‑eyes review (scan for regressions + missing tests). +- Fix issues. +- Commit with Conventional Commit. +- Move to next item. + +--- + # Security notes - Role/allowlist enforced at gateway boundary. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d918ac61f..20ba8eaed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,9 @@ importers: ui: dependencies: + '@noble/ed25519': + specifier: 3.0.0 + version: 3.0.0 dompurify: specifier: ^3.3.1 version: 3.3.1 @@ -1292,6 +1295,9 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/ed25519@3.0.0': + resolution: {integrity: sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==} + '@node-llama-cpp/linux-arm64@3.14.5': resolution: {integrity: sha512-58IcWW7EOqc/66mYWXRsoMCy1MR3pTX/YaC0HYF9Rg5XeAPKhUP7NHrglbqgjO62CkcuFZaSEiX2AtG972GQYQ==} engines: {node: '>=20.0.0'} @@ -6131,6 +6137,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/ed25519@3.0.0': {} + '@node-llama-cpp/linux-arm64@3.14.5': optional: true diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 01c3412c3..b4310d9b8 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -18,14 +18,25 @@ type JsonSchema = { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); -const outPath = path.join( - repoRoot, - "apps", - "macos", - "Sources", - "ClawdbotProtocol", - "GatewayModels.swift", -); +const outPaths = [ + path.join( + repoRoot, + "apps", + "macos", + "Sources", + "ClawdbotProtocol", + "GatewayModels.swift", + ), + path.join( + repoRoot, + "apps", + "shared", + "ClawdbotKit", + "Sources", + "ClawdbotProtocol", + "GatewayModels.swift", + ), +]; const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(ErrorCodes) .map((c) => ` case ${camelCase(c)} = "${c}"`) @@ -221,9 +232,11 @@ async function generate() { parts.push(emitGatewayFrame()); const content = parts.join("\n"); - await fs.mkdir(path.dirname(outPath), { recursive: true }); - await fs.writeFile(outPath, content); - console.log(`wrote ${outPath}`); + for (const outPath of outPaths) { + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, content); + console.log(`wrote ${outPath}`); + } } generate().catch((err) => { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 9c2da26b8..d39801f86 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -72,11 +72,14 @@ export function describeGatewayCloseCode(code: number): string | undefined { export class GatewayClient { private ws: WebSocket | null = null; - private opts: GatewayClientOptions & { deviceIdentity: DeviceIdentity }; + private opts: GatewayClientOptions; private pending = new Map(); private backoffMs = 1000; private closed = false; private lastSeq: number | null = null; + private connectNonce: string | null = null; + private connectSent = false; + private connectTimer: NodeJS.Timeout | null = null; // Track last tick to detect silent stalls. private lastTick: number | null = null; private tickIntervalMs = 30_000; @@ -121,7 +124,7 @@ export class GatewayClient { } this.ws = new WebSocket(url, wsOptions); - this.ws.on("open", () => this.sendConnect()); + this.ws.on("open", () => this.queueConnect()); this.ws.on("message", (data) => this.handleMessage(rawDataToString(data))); this.ws.on("close", (code, reason) => { const reasonText = rawDataToString(reason); @@ -147,6 +150,12 @@ export class GatewayClient { } private sendConnect() { + if (this.connectSent) return; + this.connectSent = true; + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } const role = this.opts.role ?? "operator"; const storedToken = this.opts.deviceIdentity ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token @@ -160,24 +169,29 @@ export class GatewayClient { } : undefined; const signedAtMs = Date.now(); + const nonce = this.connectNonce ?? undefined; const scopes = this.opts.scopes ?? ["operator.admin"]; - const deviceIdentity = this.opts.deviceIdentity; - const payload = buildDeviceAuthPayload({ - deviceId: deviceIdentity.deviceId, - clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, - role, - scopes, - signedAtMs, - token: authToken ?? null, - }); - const signature = signDevicePayload(deviceIdentity.privateKeyPem, payload); - const device = { - id: deviceIdentity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem), - signature, - signedAt: signedAtMs, - }; + const device = (() => { + if (!this.opts.deviceIdentity) return undefined; + const payload = buildDeviceAuthPayload({ + deviceId: this.opts.deviceIdentity.deviceId, + clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, + role, + scopes, + signedAtMs, + token: authToken ?? null, + nonce, + }); + const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload); + return { + id: this.opts.deviceIdentity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem), + signature, + signedAt: signedAtMs, + nonce, + }; + })(); const params: ConnectParams = { minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION, @@ -235,6 +249,15 @@ export class GatewayClient { const parsed = JSON.parse(raw); if (validateEventFrame(parsed)) { const evt = parsed as EventFrame; + if (evt.event === "connect.challenge") { + const payload = evt.payload as { nonce?: unknown } | undefined; + const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; + if (nonce) { + this.connectNonce = nonce; + this.sendConnect(); + } + return; + } const seq = typeof evt.seq === "number" ? evt.seq : null; if (seq !== null) { if (this.lastSeq !== null && seq > this.lastSeq + 1) { @@ -266,6 +289,15 @@ export class GatewayClient { } } + private queueConnect() { + this.connectNonce = null; + this.connectSent = false; + if (this.connectTimer) clearTimeout(this.connectTimer); + this.connectTimer = setTimeout(() => { + this.sendConnect(); + }, 750); + } + private scheduleReconnect() { if (this.closed) return; if (this.tickTimer) { diff --git a/src/gateway/device-auth.ts b/src/gateway/device-auth.ts index bf24605e6..9a70444cd 100644 --- a/src/gateway/device-auth.ts +++ b/src/gateway/device-auth.ts @@ -6,13 +6,16 @@ export type DeviceAuthPayloadParams = { scopes: string[]; signedAtMs: number; token?: string | null; + nonce?: string | null; + version?: "v1" | "v2"; }; export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { + const version = params.version ?? (params.nonce ? "v2" : "v1"); const scopes = params.scopes.join(","); const token = params.token ?? ""; - return [ - "v1", + const base = [ + version, params.deviceId, params.clientId, params.clientMode, @@ -20,5 +23,9 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string scopes, String(params.signedAtMs), token, - ].join("|"); + ]; + if (version === "v2") { + base.push(params.nonce ?? ""); + } + return base.join("|"); } diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index f9cae661c..ca2f74753 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -46,6 +46,7 @@ export const ConnectParamsSchema = Type.Object( publicKey: NonEmptyString, signature: NonEmptyString, signedAt: Type.Integer({ minimum: 0 }), + nonce: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ), diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 30bd2d1a7..a1aa20cbb 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -81,6 +81,7 @@ export function listGatewayMethods(): string[] { } export const GATEWAY_EVENTS = [ + "connect.challenge", "agent", "chat", "presence", diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index a6492bb47..181608230 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -57,6 +57,22 @@ describe("gateway server auth/connect", () => { await server.close(); }); + test("sends connect challenge on open", async () => { + const port = await getFreePort(); + const server = await startGatewayServer(port); + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const evtPromise = onceMessage<{ payload?: unknown }>( + ws, + (o) => o.type === "event" && o.event === "connect.challenge", + ); + await new Promise((resolve) => ws.once("open", resolve)); + const evt = await evtPromise; + const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + ws.close(); + await server.close(); + }); + test("rejects protocol mismatch", async () => { const { server, ws } = await startServerWithClient(); try { diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 8b30ef53b..a50e51480 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -116,6 +116,13 @@ export function attachGatewayWsConnectionHandler(params: { } }; + const connectNonce = randomUUID(); + send({ + type: "event", + event: "connect.challenge", + payload: { nonce: connectNonce, ts: Date.now() }, + }); + const close = (code = 1000, reason?: string) => { if (closed) return; closed = true; @@ -224,6 +231,7 @@ export function attachGatewayWsConnectionHandler(params: { requestOrigin, requestUserAgent, canvasHostUrl, + connectNonce, resolvedAuth, gatewayMethods, events, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 975e344ce..4d55fe1c1 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -68,6 +68,7 @@ export function attachGatewayWsMessageHandler(params: { requestOrigin?: string; requestUserAgent?: string; canvasHostUrl?: string; + connectNonce: string; resolvedAuth: ResolvedGatewayAuth; gatewayMethods: string[]; events: string[]; @@ -96,6 +97,7 @@ export function attachGatewayWsMessageHandler(params: { requestOrigin, requestUserAgent, canvasHostUrl, + connectNonce, resolvedAuth, gatewayMethods, events, @@ -307,6 +309,40 @@ export function attachGatewayWsMessageHandler(params: { close(1008, "device signature expired"); return; } + const nonceRequired = !isLoopbackAddress(remoteAddr); + const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; + if (nonceRequired && !providedNonce) { + setHandshakeState("failed"); + setCloseCause("device-auth-invalid", { + reason: "device-nonce-missing", + client: connectParams.client.id, + deviceId: device.id, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "device nonce required"), + }); + close(1008, "device nonce required"); + return; + } + if (providedNonce && providedNonce !== connectNonce) { + setHandshakeState("failed"); + setCloseCause("device-auth-invalid", { + reason: "device-nonce-mismatch", + client: connectParams.client.id, + deviceId: device.id, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "device nonce mismatch"), + }); + close(1008, "device nonce mismatch"); + return; + } const payload = buildDeviceAuthPayload({ deviceId: device.id, clientId: connectParams.client.id, @@ -315,8 +351,41 @@ export function attachGatewayWsMessageHandler(params: { scopes: requestedScopes, signedAtMs: signedAt, token: connectParams.auth?.token ?? null, + nonce: providedNonce || undefined, + version: providedNonce ? "v2" : "v1", }); - if (!verifyDeviceSignature(device.publicKey, payload, device.signature)) { + const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature); + const allowLegacy = !nonceRequired && !providedNonce; + if (!signatureOk && allowLegacy) { + const legacyPayload = buildDeviceAuthPayload({ + deviceId: device.id, + clientId: connectParams.client.id, + clientMode: connectParams.client.mode, + role, + scopes: requestedScopes, + signedAtMs: signedAt, + token: connectParams.auth?.token ?? null, + version: "v1", + }); + if (verifyDeviceSignature(device.publicKey, legacyPayload, device.signature)) { + // accepted legacy loopback signature + } else { + setHandshakeState("failed"); + setCloseCause("device-auth-invalid", { + reason: "device-signature", + client: connectParams.client.id, + deviceId: device.id, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature invalid"), + }); + close(1008, "device signature invalid"); + return; + } + } else if (!signatureOk) { setHandshakeState("failed"); setCloseCause("device-auth-invalid", { reason: "device-signature", @@ -460,11 +529,7 @@ export function attachGatewayWsMessageHandler(params: { if (!ok) return; } else { const allowedRoles = new Set( - Array.isArray(paired.roles) - ? paired.roles - : paired.role - ? [paired.role] - : [], + Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [], ); if (allowedRoles.size === 0) { const ok = await requirePairing("role-upgrade", paired); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index ba9ec7c3d..e3668815f 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -279,6 +279,7 @@ export async function connectReq( publicKey: string; signature: string; signedAt: number; + nonce?: string; }; }, ): Promise { @@ -310,6 +311,7 @@ export async function connectReq( publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, + nonce: opts?.device?.nonce, }; })(); ws.send( diff --git a/ui/package.json b/ui/package.json index 681da580b..e505c7367 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,6 +9,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { + "@noble/ed25519": "3.0.0", "dompurify": "^3.3.1", "lit": "^3.3.2", "marked": "^17.0.1", diff --git a/ui/src/ui/device-identity.ts b/ui/src/ui/device-identity.ts new file mode 100644 index 000000000..718816030 --- /dev/null +++ b/ui/src/ui/device-identity.ts @@ -0,0 +1,108 @@ +import { ed25519 } from "@noble/ed25519"; + +type StoredIdentity = { + version: 1; + deviceId: string; + publicKey: string; + privateKey: string; + createdAtMs: number; +}; + +export type DeviceIdentity = { + deviceId: string; + publicKey: string; + privateKey: string; +}; + +const STORAGE_KEY = "clawdbot-device-identity-v1"; + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function base64UrlDecode(input: string): Uint8Array { + const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function fingerprintPublicKey(publicKey: Uint8Array): Promise { + const hash = await crypto.subtle.digest("SHA-256", publicKey); + return bytesToHex(new Uint8Array(hash)); +} + +async function generateIdentity(): Promise { + const privateKey = ed25519.utils.randomPrivateKey(); + const publicKey = await ed25519.getPublicKey(privateKey); + const deviceId = await fingerprintPublicKey(publicKey); + return { + deviceId, + publicKey: base64UrlEncode(publicKey), + privateKey: base64UrlEncode(privateKey), + }; +} + +export async function loadOrCreateDeviceIdentity(): Promise { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as StoredIdentity; + if ( + parsed?.version === 1 && + typeof parsed.deviceId === "string" && + typeof parsed.publicKey === "string" && + typeof parsed.privateKey === "string" + ) { + const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); + if (derivedId !== parsed.deviceId) { + const updated: StoredIdentity = { + ...parsed, + deviceId: derivedId, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return { + deviceId: derivedId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + return { + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + } + } catch { + // fall through to regenerate + } + + const identity = await generateIdentity(); + const stored: StoredIdentity = { + version: 1, + deviceId: identity.deviceId, + publicKey: identity.publicKey, + privateKey: identity.privateKey, + createdAtMs: Date.now(), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + return identity; +} + +export async function signDevicePayload(privateKeyBase64Url: string, payload: string) { + const key = base64UrlDecode(privateKeyBase64Url); + const data = new TextEncoder().encode(payload); + const sig = await ed25519.sign(data, key); + return base64UrlEncode(sig); +} diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 3fb884d46..8c5d3f55c 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -5,6 +5,8 @@ import { type GatewayClientMode, type GatewayClientName, } from "../../../src/gateway/protocol/client-info.js"; +import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js"; +import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity"; export type GatewayEventFrame = { type: "event"; @@ -58,6 +60,9 @@ export class GatewayBrowserClient { private pending = new Map(); private closed = false; private lastSeq: number | null = null; + private connectNonce: string | null = null; + private connectSent = false; + private connectTimer: number | null = null; private backoffMs = 800; constructor(private opts: GatewayBrowserClientOptions) {} @@ -81,7 +86,7 @@ export class GatewayBrowserClient { private connect() { if (this.closed) return; this.ws = new WebSocket(this.opts.url); - this.ws.onopen = () => this.sendConnect(); + this.ws.onopen = () => this.queueConnect(); this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? "")); this.ws.onclose = (ev) => { const reason = String(ev.reason ?? ""); @@ -107,7 +112,14 @@ export class GatewayBrowserClient { this.pending.clear(); } - private sendConnect() { + private async sendConnect() { + if (this.connectSent) return; + this.connectSent = true; + if (this.connectTimer !== null) { + window.clearTimeout(this.connectTimer); + this.connectTimer = null; + } + const deviceIdentity = await loadOrCreateDeviceIdentity(); const auth = this.opts.token || this.opts.password ? { @@ -115,6 +127,21 @@ export class GatewayBrowserClient { password: this.opts.password, } : undefined; + const scopes = ["operator.admin"]; + const role = "operator"; + const signedAtMs = Date.now(); + const nonce = this.connectNonce ?? undefined; + const payload = buildDeviceAuthPayload({ + deviceId: deviceIdentity.deviceId, + clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + role, + scopes, + signedAtMs, + token: this.opts.token ?? null, + nonce, + }); + const signature = await signDevicePayload(deviceIdentity.privateKey, payload); const params = { minProtocol: 3, maxProtocol: 3, @@ -125,6 +152,15 @@ export class GatewayBrowserClient { mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, instanceId: this.opts.instanceId, }, + role, + scopes, + device: { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKey, + signature, + signedAt: signedAtMs, + nonce, + }, caps: [], auth, userAgent: navigator.userAgent, @@ -152,6 +188,15 @@ export class GatewayBrowserClient { const frame = parsed as { type?: unknown }; if (frame.type === "event") { const evt = parsed as GatewayEventFrame; + if (evt.event === "connect.challenge") { + const payload = evt.payload as { nonce?: unknown } | undefined; + const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; + if (nonce) { + this.connectNonce = nonce; + void this.sendConnect(); + } + return; + } const seq = typeof evt.seq === "number" ? evt.seq : null; if (seq !== null) { if (this.lastSeq !== null && seq > this.lastSeq + 1) { @@ -186,4 +231,13 @@ export class GatewayBrowserClient { this.ws.send(JSON.stringify(frame)); return p; } + + private queueConnect() { + this.connectNonce = null; + this.connectSent = false; + if (this.connectTimer !== null) window.clearTimeout(this.connectTimer); + this.connectTimer = window.setTimeout(() => { + void this.sendConnect(); + }, 750); + } }