From 73e9e787b4df7705556f199f5f3e00580fab38c3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 19 Jan 2026 02:31:18 +0000 Subject: [PATCH] feat: unify device auth + pairing --- .../Sources/Clawdbot/DeviceIdentity.swift | 84 ++++ .../DevicePairingApprovalPrompter.swift | 318 ++++++++++++++++ .../ExecApprovalsGatewayPrompter.swift | 58 +++ .../Sources/Clawdbot/GatewayChannel.swift | 27 +- .../Sources/Clawdbot/GatewayConnection.swift | 20 + apps/macos/Sources/Clawdbot/MenuBar.swift | 4 + .../Sources/Clawdbot/MenuContentView.swift | 8 + .../Clawdbot/TerminationSignalWatcher.swift | 1 + docs/refactor/clawnet.md | 360 ++++++++++++++++++ src/agents/bash-tools.exec.ts | 34 +- src/gateway/call.ts | 4 + src/gateway/client.ts | 31 ++ src/gateway/device-auth.ts | 24 ++ src/gateway/exec-approval-manager.ts | 74 ++++ src/gateway/protocol/index.ts | 28 ++ src/gateway/protocol/schema.ts | 1 + src/gateway/protocol/schema/devices.ts | 44 +++ src/gateway/protocol/schema/error-codes.ts | 1 + src/gateway/protocol/schema/exec-approvals.ts | 23 ++ src/gateway/protocol/schema/frames.ts | 13 + .../protocol/schema/protocol-schemas.ts | 16 + src/gateway/protocol/schema/types.ts | 12 + src/gateway/server-methods-list.ts | 9 + src/gateway/server-methods.ts | 44 +++ src/gateway/server-methods/devices.ts | 98 +++++ src/gateway/server-methods/exec-approval.ts | 105 +++++ src/gateway/server.impl.ts | 10 +- .../server/ws-connection/message-handler.ts | 171 +++++++++ src/infra/device-identity.ts | 183 +++++++++ src/infra/device-pairing.ts | 256 +++++++++++++ 30 files changed, 2041 insertions(+), 20 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/DeviceIdentity.swift create mode 100644 apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift create mode 100644 apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift create mode 100644 docs/refactor/clawnet.md create mode 100644 src/gateway/device-auth.ts create mode 100644 src/gateway/exec-approval-manager.ts create mode 100644 src/gateway/protocol/schema/devices.ts create mode 100644 src/gateway/server-methods/devices.ts create mode 100644 src/gateway/server-methods/exec-approval.ts create mode 100644 src/infra/device-identity.ts create mode 100644 src/infra/device-pairing.ts diff --git a/apps/macos/Sources/Clawdbot/DeviceIdentity.swift b/apps/macos/Sources/Clawdbot/DeviceIdentity.swift new file mode 100644 index 000000000..ad49f16d8 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/DeviceIdentity.swift @@ -0,0 +1,84 @@ +import CryptoKit +import Foundation + +struct DeviceIdentity: Codable, Sendable { + var deviceId: String + var publicKey: String + var privateKey: String + var createdAtMs: Int +} + +enum DeviceIdentityStore { + private static let fileName = "device.json" + + static func loadOrCreate() -> DeviceIdentity { + let url = self.fileURL() + if let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data), + !decoded.deviceId.isEmpty, + !decoded.publicKey.isEmpty, + !decoded.privateKey.isEmpty { + return decoded + } + let identity = self.generate() + self.save(identity) + return identity + } + + 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) + let signature = try privateKey.signature(for: Data(payload.utf8)) + return self.base64UrlEncode(signature) + } catch { + return nil + } + } + + private static func generate() -> DeviceIdentity { + let privateKey = Curve25519.Signing.PrivateKey() + let publicKey = privateKey.publicKey + let publicKeyData = publicKey.rawRepresentation + let privateKeyData = privateKey.rawRepresentation + let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined() + return DeviceIdentity( + deviceId: deviceId, + publicKey: publicKeyData.base64EncodedString(), + privateKey: privateKeyData.base64EncodedString(), + createdAtMs: Int(Date().timeIntervalSince1970 * 1000)) + } + + private static func base64UrlEncode(_ data: Data) -> String { + let base64 = data.base64EncodedString() + return base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? { + guard let data = Data(base64Encoded: identity.publicKey) else { return nil } + return self.base64UrlEncode(data) + } + + private static func save(_ identity: DeviceIdentity) { + let url = self.fileURL() + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try JSONEncoder().encode(identity) + try data.write(to: url, options: [.atomic]) + } catch { + // best-effort only + } + } + + private static func fileURL() -> URL { + let base = ClawdbotPaths.stateDirURL + return base + .appendingPathComponent("identity", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift new file mode 100644 index 000000000..bbe8e0fc6 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift @@ -0,0 +1,318 @@ +import AppKit +import ClawdbotProtocol +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class DevicePairingApprovalPrompter { + static let shared = DevicePairingApprovalPrompter() + + private let logger = Logger(subsystem: "com.clawdbot", category: "device-pairing") + private var task: Task? + private var isStopping = false + private var isPresenting = false + private var queue: [PendingRequest] = [] + var pendingCount: Int = 0 + var pendingRepairCount: Int = 0 + private var activeAlert: NSAlert? + private var activeRequestId: String? + private var alertHostWindow: NSWindow? + private var resolvedByRequestId: Set = [] + + private final class AlertHostWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + } + + private struct PairingList: Codable { + let pending: [PendingRequest] + let paired: [PairedDevice]? + } + + private struct PairedDevice: Codable, Equatable { + let deviceId: String + let approvedAtMs: Double? + let displayName: String? + let platform: String? + let remoteIp: String? + } + + private struct PendingRequest: Codable, Equatable, Identifiable { + let requestId: String + let deviceId: String + let publicKey: String + let displayName: String? + let platform: String? + let clientId: String? + let clientMode: String? + let role: String? + let scopes: [String]? + let remoteIp: String? + let silent: Bool? + let isRepair: Bool? + let ts: Double + + var id: String { self.requestId } + } + + private struct PairingResolvedEvent: Codable { + let requestId: String + let deviceId: String + let decision: String + let ts: Double + } + + private enum PairingResolution: String { + case approved + case rejected + } + + func start() { + guard self.task == nil else { return } + self.isStopping = false + self.task = Task { [weak self] in + guard let self else { return } + _ = try? await GatewayConnection.shared.refresh() + await self.loadPendingRequestsFromGateway() + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in self?.handle(push: push) } + } + } + } + + func stop() { + self.isStopping = true + self.endActiveAlert() + self.task?.cancel() + self.task = nil + self.queue.removeAll(keepingCapacity: false) + self.updatePendingCounts() + self.isPresenting = false + self.activeRequestId = nil + self.alertHostWindow?.orderOut(nil) + self.alertHostWindow?.close() + self.alertHostWindow = nil + self.resolvedByRequestId.removeAll(keepingCapacity: false) + } + + private func loadPendingRequestsFromGateway() async { + do { + let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList) + await self.apply(list: list) + } catch { + self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)") + } + } + + private func apply(list: PairingList) async { + self.queue = list.pending.sorted(by: { $0.ts > $1.ts }) + self.updatePendingCounts() + self.presentNextIfNeeded() + } + + private func updatePendingCounts() { + self.pendingCount = self.queue.count + self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count + } + + private func presentNextIfNeeded() { + guard !self.isStopping else { return } + guard !self.isPresenting else { return } + guard let next = self.queue.first else { return } + self.isPresenting = true + self.presentAlert(for: next) + } + + private func presentAlert(for req: PendingRequest) { + self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)") + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow device to connect?" + alert.informativeText = Self.describe(req) + alert.addButton(withTitle: "Later") + alert.addButton(withTitle: "Approve") + alert.addButton(withTitle: "Reject") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + + self.activeAlert = alert + self.activeRequestId = req.requestId + let hostWindow = self.requireAlertHostWindow() + + let sheetSize = alert.window.frame.size + if let screen = hostWindow.screen ?? NSScreen.main { + let bounds = screen.visibleFrame + let x = bounds.midX - (sheetSize.width / 2) + let sheetOriginY = bounds.midY - (sheetSize.height / 2) + let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height + hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) + } else { + hostWindow.center() + } + + hostWindow.makeKeyAndOrderFront(nil) + alert.beginSheetModal(for: hostWindow) { [weak self] response in + Task { @MainActor [weak self] in + guard let self else { return } + self.activeRequestId = nil + self.activeAlert = nil + await self.handleAlertResponse(response, request: req) + hostWindow.orderOut(nil) + } + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { + defer { + if self.queue.first == request { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == request } + } + self.updatePendingCounts() + self.isPresenting = false + self.presentNextIfNeeded() + } + + guard !self.isStopping else { return } + + if self.resolvedByRequestId.remove(request.requestId) != nil { + return + } + + switch response { + case .alertFirstButtonReturn: + return + case .alertSecondButtonReturn: + _ = await self.approve(requestId: request.requestId) + case .alertThirdButtonReturn: + await self.reject(requestId: request.requestId) + default: + return + } + } + + private func approve(requestId: String) async -> Bool { + do { + try await GatewayConnection.shared.devicePairApprove(requestId: requestId) + self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + self.logger.error("approve failed requestId=\(requestId, privacy: .public)") + self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func reject(requestId: String) async { + do { + try await GatewayConnection.shared.devicePairReject(requestId: requestId) + self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)") + } catch { + self.logger.error("reject failed requestId=\(requestId, privacy: .public)") + self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func endActiveAlert() { + guard let alert = self.activeAlert else { return } + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .abort) + } + self.activeAlert = nil + self.activeRequestId = nil + } + + private func requireAlertHostWindow() -> NSWindow { + if let alertHostWindow { + return alertHostWindow + } + + let window = AlertHostWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.title = "" + window.isReleasedWhenClosed = false + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isOpaque = false + window.hasShadow = false + window.backgroundColor = .clear + window.ignoresMouseEvents = true + + self.alertHostWindow = window + return window + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "device.pair.requested": + guard let payload = evt.payload else { return } + do { + let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) + self.enqueue(req) + } catch { + self.logger.error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)") + } + case let .event(evt) where evt.event == "device.pair.resolved": + guard let payload = evt.payload else { return } + do { + let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) + self.handleResolved(resolved) + } catch { + self.logger.error("failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)") + } + default: + break + } + } + + private func enqueue(_ req: PendingRequest) { + guard !self.queue.contains(req) else { return } + self.queue.append(req) + self.updatePendingCounts() + self.presentNextIfNeeded() + } + + private func handleResolved(_ resolved: PairingResolvedEvent) { + let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution.approved : .rejected + if let activeRequestId, activeRequestId == resolved.requestId { + self.resolvedByRequestId.insert(resolved.requestId) + self.endActiveAlert() + self.logger.info("device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) decision=\(resolution.rawValue, privacy: .public)") + return + } + self.queue.removeAll { $0.requestId == resolved.requestId } + self.updatePendingCounts() + } + + private static func describe(_ req: PendingRequest) -> String { + var lines: [String] = [] + lines.append("Device: \(req.displayName ?? req.deviceId)") + if let platform = req.platform { + lines.append("Platform: \(platform)") + } + if let role = req.role { + lines.append("Role: \(role)") + } + if let scopes = req.scopes, !scopes.isEmpty { + lines.append("Scopes: \(scopes.joined(separator: \", \"))") + } + if let remoteIp = req.remoteIp { + lines.append("IP: \(remoteIp)") + } + if req.isRepair == true { + lines.append("Repair: yes") + } + return lines.joined(separator: "\n") + } +} diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift new file mode 100644 index 000000000..efdb4be68 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift @@ -0,0 +1,58 @@ +import ClawdbotProtocol +import Foundation +import OSLog + +@MainActor +final class ExecApprovalsGatewayPrompter { + static let shared = ExecApprovalsGatewayPrompter() + + private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.gateway") + private var task: Task? + + struct GatewayApprovalRequest: Codable, Sendable { + var id: String + var request: ExecApprovalPromptRequest + var createdAtMs: Int + var expiresAtMs: Int + } + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + await self?.run() + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + private func run() async { + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await self.handle(push: push) + } + } + + private func handle(push: GatewayPush) async { + guard case let .event(evt) = push else { return } + guard evt.event == "exec.approval.requested" else { return } + guard let payload = evt.payload else { return } + do { + let data = try JSONEncoder().encode(payload) + let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) + let decision = ExecApprovalsPromptPresenter.prompt(request.request) + try await GatewayConnection.shared.requestVoid( + method: .execApprovalResolve, + params: [ + "id": AnyCodable(request.id), + "decision": AnyCodable(decision.rawValue), + ], + timeoutMs: 10_000) + } catch { + self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/apps/macos/Sources/Clawdbot/GatewayChannel.swift b/apps/macos/Sources/Clawdbot/GatewayChannel.swift index 81fe805cb..b777b6a17 100644 --- a/apps/macos/Sources/Clawdbot/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdbot/GatewayChannel.swift @@ -204,6 +204,7 @@ actor GatewayChannelActor { let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let clientDisplayName = InstanceIdentity.displayName let clientId = "clawdbot-macos" + let clientMode = "ui" let reqId = UUID().uuidString var client: [String: ProtoAnyCodable] = [ @@ -212,7 +213,7 @@ actor GatewayChannelActor { "version": ProtoAnyCodable( Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), "platform": ProtoAnyCodable(platform), - "mode": ProtoAnyCodable("ui"), + "mode": ProtoAnyCodable(clientMode), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), ] client["deviceFamily"] = ProtoAnyCodable("Mac") @@ -226,12 +227,36 @@ actor GatewayChannelActor { "caps": ProtoAnyCodable([] as [String]), "locale": ProtoAnyCodable(primaryLocale), "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), + "role": ProtoAnyCodable("operator"), + "scopes": ProtoAnyCodable(["operator.admin", "operator.approvals", "operator.pairing"]), ] if let token = self.token { params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) } else if let password = self.password { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } + let identity = DeviceIdentityStore.loadOrCreate() + let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let scopes = "operator.admin,operator.approvals,operator.pairing" + let payload = [ + "v1", + identity.deviceId, + clientId, + clientMode, + "operator", + scopes, + String(signedAtMs), + self.token ?? "", + ].joined(separator: "|") + if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), + let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { + params["device"] = ProtoAnyCodable([ + "id": ProtoAnyCodable(identity.deviceId), + "publicKey": ProtoAnyCodable(publicKey), + "signature": ProtoAnyCodable(signature), + "signedAt": ProtoAnyCodable(signedAtMs), + ]) + } let frame = RequestFrame( type: "req", diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 477a65954..c8e5ddd7a 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -76,6 +76,10 @@ actor GatewayConnection { case voicewakeSet = "voicewake.set" case nodePairApprove = "node.pair.approve" case nodePairReject = "node.pair.reject" + case devicePairList = "device.pair.list" + case devicePairApprove = "device.pair.approve" + case devicePairReject = "device.pair.reject" + case execApprovalResolve = "exec.approval.resolve" case cronList = "cron.list" case cronRuns = "cron.runs" case cronRun = "cron.run" @@ -610,6 +614,22 @@ extension GatewayConnection { timeoutMs: 10000) } + // MARK: - Device pairing + + func devicePairApprove(requestId: String) async throws { + try await self.requestVoid( + method: .devicePairApprove, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + func devicePairReject(requestId: String) async throws { + try await self.requestVoid( + method: .devicePairReject, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + // MARK: - Cron struct CronSchedulerStatus: Decodable, Sendable { diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift index 01c626f0d..df58c04c2 100644 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ b/apps/macos/Sources/Clawdbot/MenuBar.swift @@ -256,7 +256,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } TerminationSignalWatcher.shared.start() NodePairingApprovalPrompter.shared.start() + DevicePairingApprovalPrompter.shared.start() ExecApprovalsPromptServer.shared.start() + ExecApprovalsGatewayPrompter.shared.start() MacNodeModeCoordinator.shared.start() VoiceWakeGlobalSettingsSync.shared.start() Task { PresenceReporter.shared.start() } @@ -281,7 +283,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { PresenceReporter.shared.stop() NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() ExecApprovalsPromptServer.shared.stop() + ExecApprovalsGatewayPrompter.shared.stop() MacNodeModeCoordinator.shared.stop() TerminationSignalWatcher.shared.stop() VoiceWakeGlobalSettingsSync.shared.stop() diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Clawdbot/MenuContentView.swift index 049e1de9e..8dbeb8f44 100644 --- a/apps/macos/Sources/Clawdbot/MenuContentView.swift +++ b/apps/macos/Sources/Clawdbot/MenuContentView.swift @@ -15,6 +15,7 @@ struct MenuContent: View { private let controlChannel = ControlChannel.shared private let activityStore = WorkActivityStore.shared @Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared + @Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared @Environment(\.openSettings) private var openSettings @State private var availableMics: [AudioInputDevice] = [] @State private var loadingMics = false @@ -50,6 +51,13 @@ struct MenuContent: View { label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)", color: .orange) } + if self.devicePairingPrompter.pendingCount > 0 { + let repairCount = self.devicePairingPrompter.pendingRepairCount + let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : "" + self.statusLine( + label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)", + color: .orange) + } } } .disabled(self.state.connectionMode == .unconfigured) diff --git a/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift b/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift index e00188521..7994016ef 100644 --- a/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift +++ b/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift @@ -42,6 +42,7 @@ final class TerminationSignalWatcher { self.logger.info("received signal \(sig, privacy: .public); terminating") // Ensure any pairing prompt can't accidentally approve during shutdown. NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() NSApp.terminate(nil) // Safety net: don't hang forever if something blocks termination. diff --git a/docs/refactor/clawnet.md b/docs/refactor/clawnet.md new file mode 100644 index 000000000..ac7eec571 --- /dev/null +++ b/docs/refactor/clawnet.md @@ -0,0 +1,360 @@ +--- +summary: "Clawnet refactor: unify network protocol, roles, auth, approvals, identity" +read_when: + - Planning a unified network protocol for nodes + operator clients + - Reworking approvals, pairing, TLS, and presence across devices +--- +# Clawnet refactor (protocol + auth unification) + +## Hi +Hi Peter — great direction; this unlocks simpler UX + stronger security. + +## Purpose +Single, rigorous document for: +- Current state: protocols, flows, trust boundaries. +- Pain points: approvals, multi‑hop routing, UI duplication. +- Proposed new state: one protocol, scoped roles, unified auth/pairing, TLS pinning. +- Identity model: stable IDs + cute slugs. +- Migration plan, risks, open questions. + +## Goals (from discussion) +- One protocol for all clients (mac app, CLI, iOS, Android, headless node). +- Every network participant authenticated + paired. +- Role clarity: nodes vs operators. +- Central approvals routed to where the user is. +- TLS encryption + optional pinning for all remote traffic. +- Minimal code duplication. +- Single machine should appear once (no UI/node duplicate entry). + +## Non‑goals (explicit) +- Remove capability separation (still need least‑privilege). +- Expose full gateway control plane without scope checks. +- Make auth depend on human labels (slugs remain non‑security). + +--- + +# Current state (as‑is) + +## Two protocols + +### 1) Gateway WebSocket (control plane) +- Full API surface: config, channels, models, sessions, agent runs, logs, nodes, etc. +- Default bind: loopback. Remote access via SSH/Tailscale. +- Auth: token/password via `connect`. +- No TLS pinning (relies on loopback/tunnel). +- Code: + - `src/gateway/server/ws-connection/message-handler.ts` + - `src/gateway/client.ts` + - `docs/gateway/protocol.md` + +### 2) Bridge (node transport) +- Narrow allowlist surface, node identity + pairing. +- JSONL over TCP; optional TLS + cert fingerprint pinning. +- TLS advertises fingerprint in discovery TXT. +- Code: + - `src/infra/bridge/server/connection.ts` + - `src/gateway/server-bridge.ts` + - `src/node-host/bridge-client.ts` + - `docs/gateway/bridge-protocol.md` + +## Control plane clients today +- CLI → Gateway WS via `callGateway` (`src/gateway/call.ts`). +- macOS app UI → Gateway WS (`GatewayConnection`). +- Web Control UI → Gateway WS. +- ACP → Gateway WS. +- Browser control uses its own HTTP control server. + +## Nodes today +- macOS app in node mode connects to Gateway bridge (`MacNodeBridgeSession`). +- iOS/Android apps connect to Gateway bridge. +- Pairing + per‑node token stored on gateway. + +## Current approval flow (exec) +- Agent uses `system.run` via Gateway. +- Gateway invokes node over bridge. +- Node runtime decides approval. +- UI prompt shown by mac app (when node == mac app). +- Node returns `invoke-res` to Gateway. +- Multi‑hop, UI tied to node host. + +## Presence + identity today +- Gateway presence entries from WS clients. +- Node presence entries from bridge. +- mac app can show two entries for same machine (UI + node). +- Node identity stored in pairing store; UI identity separate. + +--- + +# Problems / pain points + +- Two protocol stacks to maintain (WS + Bridge). +- Approvals on remote nodes: prompt appears on node host, not where user is. +- TLS pinning only exists for bridge; WS depends on SSH/Tailscale. +- Identity duplication: same machine shows as multiple instances. +- Ambiguous roles: UI + node + CLI capabilities not clearly separated. + +--- + +# Proposed new state (Clawnet) + +## One protocol, two roles +Single WS protocol with role + scope. +- **Role: node** (capability host) +- **Role: operator** (control plane) +- Optional **scope** for operator: + - `operator.read` (status + viewing) + - `operator.write` (agent run, sends) + - `operator.admin` (config, channels, models) + +### Role behaviors + +**Node** +- Can register capabilities (`caps`, `commands`, permissions). +- Can receive `invoke` commands (`system.run`, `camera.*`, `canvas.*`, `screen.record`, etc). +- Can send events: `voice.transcript`, `agent.request`, `chat.subscribe`. +- Cannot call config/models/channels/sessions/agent control plane APIs. + +**Operator** +- Full control plane API, gated by scope. +- Receives all approvals. +- Does not directly execute OS actions; routes to nodes. + +### Key rule +Role is per‑connection, not per device. A device may open both roles, separately. + +--- + +# Unified authentication + pairing + +## Client identity +Every client provides: +- `deviceId` (stable, derived from device key). +- `displayName` (human name). +- `role` + `scope` + `caps` + `commands`. + +## Pairing flow (unified) +- Client connects unauthenticated. +- Gateway creates a **pairing request** for that `deviceId`. +- Operator receives prompt; approves/denies. +- Gateway issues credentials bound to: + - device public key + - role(s) + - scope(s) + - capabilities/commands +- Client persists token, reconnects authenticated. + +## Device‑bound auth (avoid bearer token replay) +Preferred: device keypairs. +- Device generates keypair once. +- `deviceId = fingerprint(publicKey)`. +- Gateway sends nonce; device signs; gateway verifies. +- Tokens are issued to a public key (proof‑of‑possession), not a string. + +Alternatives: +- mTLS (client certs): strongest, more ops complexity. +- Short‑lived bearer tokens only as a temporary phase (rotate + revoke early). + +## Silent approval (SSH heuristic) +Define it precisely to avoid a weak link. Prefer one: +- **Local‑only**: auto‑pair when client connects via loopback/Unix socket. +- **Challenge via SSH**: gateway issues nonce; client proves SSH by fetching it. +- **Physical presence window**: after a local approval on gateway host UI, allow auto‑pair for a short window (e.g. 10 minutes). + +Always log + record auto‑approvals. + +--- + +# TLS everywhere (dev + prod) + +## Reuse existing bridge TLS +Use current TLS runtime + fingerprint pinning: +- `src/infra/bridge/server/tls.ts` +- fingerprint verification logic in `src/node-host/bridge-client.ts` + +## Apply to WS +- WS server supports TLS with same cert/key + fingerprint. +- WS clients can pin fingerprint (optional). +- Discovery advertises TLS + fingerprint for all endpoints. + - Discovery is locator hints only; never a trust anchor. + +## Why +- Reduce reliance on SSH/Tailscale for confidentiality. +- Make remote mobile connections safe by default. + +--- + +# Approvals redesign (centralized) + +## Current +Approval happens on node host (mac app node runtime). Prompt appears where node runs. + +## Proposed +Approval is **gateway‑hosted**, UI delivered to operator clients. + +### New flow +1) Gateway receives `system.run` intent (agent). +2) Gateway creates approval record: `approval.requested`. +3) Operator UI(s) show prompt. +4) Approval decision sent to gateway: `approval.resolve`. +5) Gateway invokes node command if approved. +6) Node executes, returns `invoke-res`. + +### Approval semantics (hardening) +- Broadcast to all operators; only the active UI shows a modal (others get a toast). +- First resolution wins; gateway rejects subsequent resolves as already settled. +- Default timeout: deny after N seconds (e.g. 60s), log reason. +- Resolution requires `operator.approvals` scope. + +## Benefits +- Prompt appears where user is (mac/phone). +- Consistent approvals for remote nodes. +- Node runtime stays headless; no UI dependency. + +--- + +# Role clarity examples + +## iPhone app +- **Node role** for: mic, camera, voice chat, location, push‑to‑talk. +- Optional **operator.read** for status and chat view. +- Optional **operator.write/admin** only when explicitly enabled. + +## macOS app +- Operator role by default (control UI). +- Node role when “Mac node” enabled (system.run, screen, camera). +- Same deviceId for both connections → merged UI entry. + +## CLI +- Operator role always. +- Scope derived by subcommand: + - `status`, `logs` → read + - `agent`, `message` → write + - `config`, `channels` → admin + - approvals + pairing → `operator.approvals` / `operator.pairing` + +--- + +# Identity + slugs + +## Stable ID +Required for auth; never changes. +Preferred: +- Keypair fingerprint (public key hash). + +## Cute slug (lobster‑themed) +Human label only. +- Example: `scarlet-claw`, `saltwave`, `mantis-pinch`. +- Stored in gateway registry, editable. +- Collision handling: `-2`, `-3`. + +## UI grouping +Same `deviceId` across roles → single “Instance” row: +- Badge: `operator`, `node`. +- Shows capabilities + last seen. + +--- + +# Migration strategy + +## Phase 0: Document + align +- Publish this doc. +- Inventory all protocol calls + approval flows. + +## Phase 1: Add roles/scopes to WS +- Extend `connect` params with `role`, `scope`, `deviceId`. +- Add allowlist gating for node role. + +## Phase 2: Bridge compatibility +- Keep bridge running. +- Add WS node support in parallel. +- Gate features behind config flag. + +## Phase 3: Central approvals +- Add approval request + resolve events in WS. +- Update mac app UI to prompt + respond. +- Node runtime stops prompting UI. + +## Phase 4: TLS unification +- Add TLS config for WS using bridge TLS runtime. +- Add pinning to clients. + +## Phase 5: Deprecate bridge +- Migrate iOS/Android/mac node to WS. +- Keep bridge as fallback; remove once stable. + +## Phase 6: Device‑bound auth +- Require key‑based identity for all non‑local connections. +- Add revocation + rotation UI. + +--- + +# Security notes + +- Role/allowlist enforced at gateway boundary. +- No client gets “full” API without operator scope. +- Pairing required for *all* connections. +- TLS + pinning reduces MITM risk for mobile. +- SSH silent approval is a convenience; still recorded + revocable. +- Discovery is never a trust anchor. +- Capability claims are verified against server allowlists by platform/type. + +# Streaming + large payloads (node media) +WS control plane is fine for small messages, but nodes also do: +- camera clips +- screen recordings +- audio streams + +Options: +1) WS binary frames + chunking + backpressure rules. +2) Separate streaming endpoint (still TLS + auth). +3) Keep bridge longer for media‑heavy commands, migrate last. + +Pick one before implementation to avoid drift. + +# Capability + command policy +- Node‑reported caps/commands are treated as **claims**. +- Gateway enforces per‑platform allowlists. +- Any new command requires operator approval or explicit allowlist change. +- Audit changes with timestamps. + +# Audit + rate limiting +- Log: pairing requests, approvals/denials, token issuance/rotation/revocation. +- Rate‑limit pairing spam and approval prompts. + +# Protocol hygiene +- Explicit protocol version + error codes. +- Reconnect rules + heartbeat policy. +- Presence TTL and last‑seen semantics. + +--- + +# Open questions + +1) Single device running both roles: token model + - Recommend separate tokens per role (node vs operator). + - Same deviceId; different scopes; clearer revocation. + +2) Operator scope granularity + - read/write/admin + approvals + pairing (minimum viable). + - Consider per‑feature scopes later. + +3) Token rotation + revocation UX + - Auto‑rotate on role change. + - UI to revoke by deviceId + role. + +4) Discovery + - Extend current Bonjour TXT to include WS TLS fingerprint + role hints. + - Treat as locator hints only. + +5) Cross‑network approval + - Broadcast to all operator clients; active UI shows modal. + - First response wins; gateway enforces atomicity. + +--- + +# Summary (TL;DR) + +- Today: WS control plane + Bridge node transport. +- Pain: approvals + duplication + two stacks. +- Proposal: one WS protocol with explicit roles + scopes, unified pairing + TLS pinning, gateway‑hosted approvals, stable device IDs + cute slugs. +- Outcome: simpler UX, stronger security, less duplication, better mobile routing. diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 316a54040..fc5902e9f 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -13,7 +13,6 @@ import { maxAsk, minSecurity, recordAllowlistUse, - requestExecApprovalViaSocket, resolveCommandResolution, resolveExecApprovals, } from "../infra/exec-approvals.js"; @@ -526,20 +525,21 @@ export function createExecTool( let approvedByAsk = false; if (requiresAsk) { + const decisionResult = (await callGatewayTool("exec.approval.request", {}, { + command: params.command, + cwd: workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: defaults?.agentId, + resolvedPath: resolution?.resolvedPath ?? null, + sessionKey: defaults?.sessionKey ?? null, + timeoutMs: 120_000, + })) as { decision?: string } | null; const decision = - (await requestExecApprovalViaSocket({ - socketPath: approvals.socketPath, - token: approvals.token, - request: { - command: params.command, - cwd: workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId: defaults?.agentId, - resolvedPath: resolution?.resolvedPath ?? null, - }, - })) ?? null; + decisionResult && typeof decisionResult === "object" + ? decisionResult.decision ?? null + : null; if (decision === "deny") { throw new Error("exec denied: user denied"); @@ -550,14 +550,12 @@ export function createExecTool( } else if (askFallback === "allowlist") { if (!allowlistMatch) { throw new Error( - "exec denied: approval required (companion app approval UI not available)", + "exec denied: approval required (approval UI not available)", ); } approvedByAsk = true; } else { - throw new Error( - "exec denied: approval required (companion app approval UI not available)", - ); + throw new Error("exec denied: approval required (approval UI not available)"); } } if (decision === "allow-once") { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index c1f3b2798..1882c5543 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -7,6 +7,7 @@ import { resolveStateDir, } from "../config/config.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; +import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -186,6 +187,9 @@ export async function callGateway(opts: CallGatewayOptions): Promis clientVersion: opts.clientVersion ?? "dev", platform: opts.platform, mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, + role: "operator", + scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + deviceIdentity: loadOrCreateDeviceIdentity(), minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async () => { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index f70deb438..3e15acbd5 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -2,12 +2,15 @@ import { randomUUID } from "node:crypto"; import { WebSocket } from "ws"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; +import type { DeviceIdentity } from "../infra/device-identity.js"; +import { publicKeyRawBase64UrlFromPem, signDevicePayload } from "../infra/device-identity.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, type GatewayClientMode, type GatewayClientName, } from "../utils/message-channel.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; import { type ConnectParams, type EventFrame, @@ -35,6 +38,9 @@ export type GatewayClientOptions = { clientVersion?: string; platform?: string; mode?: GatewayClientMode; + role?: string; + scopes?: string[]; + deviceIdentity?: DeviceIdentity; minProtocol?: number; maxProtocol?: number; onEvent?: (evt: EventFrame) => void; @@ -110,6 +116,28 @@ export class GatewayClient { password: this.opts.password, } : undefined; + const signedAtMs = Date.now(); + const role = this.opts.role ?? "operator"; + const scopes = this.opts.scopes ?? ["operator.admin"]; + 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: this.opts.token ?? null, + }); + const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload); + return { + id: this.opts.deviceIdentity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem), + signature, + signedAt: signedAtMs, + }; + })(); const params: ConnectParams = { minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION, @@ -123,6 +151,9 @@ export class GatewayClient { }, caps: [], auth, + role, + scopes, + device, }; void this.request("connect", params) diff --git a/src/gateway/device-auth.ts b/src/gateway/device-auth.ts new file mode 100644 index 000000000..bf24605e6 --- /dev/null +++ b/src/gateway/device-auth.ts @@ -0,0 +1,24 @@ +export type DeviceAuthPayloadParams = { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; +}; + +export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { + const scopes = params.scopes.join(","); + const token = params.token ?? ""; + return [ + "v1", + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + ].join("|"); +} diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts new file mode 100644 index 000000000..c68f271fd --- /dev/null +++ b/src/gateway/exec-approval-manager.ts @@ -0,0 +1,74 @@ +import { randomUUID } from "node:crypto"; + +import type { ExecApprovalDecision } from "../infra/exec-approvals.js"; + +export type ExecApprovalRequestPayload = { + command: string; + cwd?: string | null; + host?: string | null; + security?: string | null; + ask?: string | null; + agentId?: string | null; + resolvedPath?: string | null; + sessionKey?: string | null; +}; + +export type ExecApprovalRecord = { + id: string; + request: ExecApprovalRequestPayload; + createdAtMs: number; + expiresAtMs: number; + resolvedAtMs?: number; + decision?: ExecApprovalDecision; + resolvedBy?: string | null; +}; + +type PendingEntry = { + record: ExecApprovalRecord; + resolve: (decision: ExecApprovalDecision) => void; + reject: (err: Error) => void; + timer: ReturnType; +}; + +export class ExecApprovalManager { + private pending = new Map(); + + create(request: ExecApprovalRequestPayload, timeoutMs: number): ExecApprovalRecord { + const now = Date.now(); + const id = randomUUID(); + const record: ExecApprovalRecord = { + id, + request, + createdAtMs: now, + expiresAtMs: now + timeoutMs, + }; + return record; + } + + async waitForDecision(record: ExecApprovalRecord, timeoutMs: number): Promise { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(record.id); + resolve("deny"); + }, timeoutMs); + this.pending.set(record.id, { record, resolve, reject, timer }); + }); + } + + resolve(recordId: string, decision: ExecApprovalDecision, resolvedBy?: string | null): boolean { + const pending = this.pending.get(recordId); + if (!pending) return false; + clearTimeout(pending.timer); + pending.record.resolvedAtMs = Date.now(); + pending.record.decision = decision; + pending.record.resolvedBy = resolvedBy ?? null; + this.pending.delete(recordId); + pending.resolve(decision); + return true; + } + + getSnapshot(recordId: string): ExecApprovalRecord | null { + const entry = this.pending.get(recordId); + return entry?.record ?? null; + } +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 983e94ed8..084603846 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -56,6 +56,12 @@ import { CronStatusParamsSchema, type CronUpdateParams, CronUpdateParamsSchema, + type DevicePairApproveParams, + DevicePairApproveParamsSchema, + type DevicePairListParams, + DevicePairListParamsSchema, + type DevicePairRejectParams, + DevicePairRejectParamsSchema, type ExecApprovalsGetParams, ExecApprovalsGetParamsSchema, type ExecApprovalsNodeGetParams, @@ -65,6 +71,10 @@ import { type ExecApprovalsSetParams, ExecApprovalsSetParamsSchema, type ExecApprovalsSnapshot, + type ExecApprovalRequestParams, + ExecApprovalRequestParamsSchema, + type ExecApprovalResolveParams, + ExecApprovalResolveParamsSchema, ErrorCodes, type ErrorShape, ErrorShapeSchema, @@ -239,12 +249,27 @@ export const validateCronUpdateParams = ajv.compile(CronUpdate export const validateCronRemoveParams = ajv.compile(CronRemoveParamsSchema); export const validateCronRunParams = ajv.compile(CronRunParamsSchema); export const validateCronRunsParams = ajv.compile(CronRunsParamsSchema); +export const validateDevicePairListParams = ajv.compile( + DevicePairListParamsSchema, +); +export const validateDevicePairApproveParams = ajv.compile( + DevicePairApproveParamsSchema, +); +export const validateDevicePairRejectParams = ajv.compile( + DevicePairRejectParamsSchema, +); export const validateExecApprovalsGetParams = ajv.compile( ExecApprovalsGetParamsSchema, ); export const validateExecApprovalsSetParams = ajv.compile( ExecApprovalsSetParamsSchema, ); +export const validateExecApprovalRequestParams = ajv.compile( + ExecApprovalRequestParamsSchema, +); +export const validateExecApprovalResolveParams = ajv.compile( + ExecApprovalResolveParamsSchema, +); export const validateExecApprovalsNodeGetParams = ajv.compile( ExecApprovalsNodeGetParamsSchema, ); @@ -364,6 +389,9 @@ export type { NodePairRequestParams, NodePairListParams, NodePairApproveParams, + DevicePairListParams, + DevicePairApproveParams, + DevicePairRejectParams, ConfigGetParams, ConfigSetParams, ConfigApplyParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 6880b2928..614942008 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -5,6 +5,7 @@ export * from "./schema/config.js"; export * from "./schema/cron.js"; export * from "./schema/error-codes.js"; export * from "./schema/exec-approvals.js"; +export * from "./schema/devices.js"; export * from "./schema/frames.js"; export * from "./schema/logs-chat.js"; export * from "./schema/nodes.js"; diff --git a/src/gateway/protocol/schema/devices.ts b/src/gateway/protocol/schema/devices.ts new file mode 100644 index 000000000..a7633abdd --- /dev/null +++ b/src/gateway/protocol/schema/devices.ts @@ -0,0 +1,44 @@ +import { Type } from "@sinclair/typebox"; + +import { NonEmptyString } from "./primitives.js"; + +export const DevicePairListParamsSchema = Type.Object({}, { additionalProperties: false }); + +export const DevicePairApproveParamsSchema = Type.Object( + { requestId: NonEmptyString }, + { additionalProperties: false }, +); + +export const DevicePairRejectParamsSchema = Type.Object( + { requestId: NonEmptyString }, + { additionalProperties: false }, +); + +export const DevicePairRequestedEventSchema = Type.Object( + { + requestId: NonEmptyString, + deviceId: NonEmptyString, + publicKey: NonEmptyString, + displayName: Type.Optional(NonEmptyString), + platform: Type.Optional(NonEmptyString), + clientId: Type.Optional(NonEmptyString), + clientMode: Type.Optional(NonEmptyString), + role: Type.Optional(NonEmptyString), + scopes: Type.Optional(Type.Array(NonEmptyString)), + remoteIp: Type.Optional(NonEmptyString), + silent: Type.Optional(Type.Boolean()), + isRepair: Type.Optional(Type.Boolean()), + ts: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); + +export const DevicePairResolvedEventSchema = Type.Object( + { + requestId: NonEmptyString, + deviceId: NonEmptyString, + decision: NonEmptyString, + ts: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/error-codes.ts b/src/gateway/protocol/schema/error-codes.ts index 108c28534..37e002a79 100644 --- a/src/gateway/protocol/schema/error-codes.ts +++ b/src/gateway/protocol/schema/error-codes.ts @@ -2,6 +2,7 @@ import type { ErrorShape } from "./types.js"; export const ErrorCodes = { NOT_LINKED: "NOT_LINKED", + NOT_PAIRED: "NOT_PAIRED", AGENT_TIMEOUT: "AGENT_TIMEOUT", INVALID_REQUEST: "INVALID_REQUEST", UNAVAILABLE: "UNAVAILABLE", diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index ac744fdb7..ae2caf77c 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -86,3 +86,26 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object( }, { additionalProperties: false }, ); + +export const ExecApprovalRequestParamsSchema = Type.Object( + { + command: NonEmptyString, + cwd: Type.Optional(Type.String()), + host: Type.Optional(Type.String()), + security: Type.Optional(Type.String()), + ask: Type.Optional(Type.String()), + agentId: Type.Optional(Type.String()), + resolvedPath: Type.Optional(Type.String()), + sessionKey: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), + }, + { additionalProperties: false }, +); + +export const ExecApprovalResolveParamsSchema = Type.Object( + { + id: NonEmptyString, + decision: NonEmptyString, + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index 2a0de92c8..41279ab64 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -35,6 +35,19 @@ export const ConnectParamsSchema = Type.Object( { additionalProperties: false }, ), caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })), + role: Type.Optional(NonEmptyString), + scopes: Type.Optional(Type.Array(NonEmptyString)), + device: Type.Optional( + Type.Object( + { + id: NonEmptyString, + publicKey: NonEmptyString, + signature: NonEmptyString, + signedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, + ), + ), auth: Type.Optional( Type.Object( { diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 88f8f7cbc..dc13885d0 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -53,7 +53,16 @@ import { ExecApprovalsNodeSetParamsSchema, ExecApprovalsSetParamsSchema, ExecApprovalsSnapshotSchema, + ExecApprovalRequestParamsSchema, + ExecApprovalResolveParamsSchema, } from "./exec-approvals.js"; +import { + DevicePairApproveParamsSchema, + DevicePairListParamsSchema, + DevicePairRejectParamsSchema, + DevicePairRequestedEventSchema, + DevicePairResolvedEventSchema, +} from "./devices.js"; import { ConnectParamsSchema, ErrorShapeSchema, @@ -182,6 +191,13 @@ export const ProtocolSchemas: Record = { ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema, ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema, ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema, + ExecApprovalRequestParams: ExecApprovalRequestParamsSchema, + ExecApprovalResolveParams: ExecApprovalResolveParamsSchema, + DevicePairListParams: DevicePairListParamsSchema, + DevicePairApproveParams: DevicePairApproveParamsSchema, + DevicePairRejectParams: DevicePairRejectParamsSchema, + DevicePairRequestedEvent: DevicePairRequestedEventSchema, + DevicePairResolvedEvent: DevicePairResolvedEventSchema, ChatHistoryParams: ChatHistoryParamsSchema, ChatSendParams: ChatSendParamsSchema, ChatAbortParams: ChatAbortParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index cbcb165d9..3a7266bea 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -51,7 +51,14 @@ import type { ExecApprovalsNodeSetParamsSchema, ExecApprovalsSetParamsSchema, ExecApprovalsSnapshotSchema, + ExecApprovalRequestParamsSchema, + ExecApprovalResolveParamsSchema, } from "./exec-approvals.js"; +import type { + DevicePairApproveParamsSchema, + DevicePairListParamsSchema, + DevicePairRejectParamsSchema, +} from "./devices.js"; import type { ConnectParamsSchema, ErrorShapeSchema, @@ -175,6 +182,11 @@ export type ExecApprovalsSetParams = Static export type ExecApprovalsNodeGetParams = Static; export type ExecApprovalsNodeSetParams = Static; export type ExecApprovalsSnapshot = Static; +export type ExecApprovalRequestParams = Static; +export type ExecApprovalResolveParams = Static; +export type DevicePairListParams = Static; +export type DevicePairApproveParams = Static; +export type DevicePairRejectParams = Static; export type ChatAbortParams = Static; export type ChatInjectParams = Static; export type ChatEvent = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 85b427fc0..2ac5101b9 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -17,6 +17,8 @@ const BASE_METHODS = [ "exec.approvals.set", "exec.approvals.node.get", "exec.approvals.node.set", + "exec.approval.request", + "exec.approval.resolve", "wizard.start", "wizard.next", "wizard.cancel", @@ -43,6 +45,9 @@ const BASE_METHODS = [ "node.pair.approve", "node.pair.reject", "node.pair.verify", + "device.pair.list", + "device.pair.approve", + "device.pair.reject", "node.rename", "node.list", "node.describe", @@ -82,5 +87,9 @@ export const GATEWAY_EVENTS = [ "cron", "node.pair.requested", "node.pair.resolved", + "device.pair.requested", + "device.pair.resolved", "voicewake.changed", + "exec.approval.requested", + "exec.approval.resolved", ]; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 5e1a069ba..c16de5f37 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -6,6 +6,7 @@ import { chatHandlers } from "./server-methods/chat.js"; import { configHandlers } from "./server-methods/config.js"; import { connectHandlers } from "./server-methods/connect.js"; import { cronHandlers } from "./server-methods/cron.js"; +import { deviceHandlers } from "./server-methods/devices.js"; import { execApprovalsHandlers } from "./server-methods/exec-approvals.js"; import { healthHandlers } from "./server-methods/health.js"; import { logsHandlers } from "./server-methods/logs.js"; @@ -23,6 +24,43 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js"; import { webHandlers } from "./server-methods/web.js"; import { wizardHandlers } from "./server-methods/wizard.js"; +const ADMIN_SCOPE = "operator.admin"; +const APPROVALS_SCOPE = "operator.approvals"; +const PAIRING_SCOPE = "operator.pairing"; + +const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); +const PAIRING_METHODS = new Set([ + "node.pair.request", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.verify", + "device.pair.list", + "device.pair.approve", + "device.pair.reject", +]); +const ADMIN_METHOD_PREFIXES = ["exec.approvals."]; + +function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { + if (!client?.connect) return null; + const role = client.connect.role ?? "operator"; + const scopes = client.connect.scopes ?? []; + if (role !== "operator") { + return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); + } + if (scopes.includes(ADMIN_SCOPE)) return null; + if (APPROVAL_METHODS.has(method) && !scopes.includes(APPROVALS_SCOPE)) { + return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals"); + } + if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) { + return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing"); + } + if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { + return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); + } + return null; +} + export const coreGatewayHandlers: GatewayRequestHandlers = { ...connectHandlers, ...logsHandlers, @@ -31,6 +69,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...channelsHandlers, ...chatHandlers, ...cronHandlers, + ...deviceHandlers, ...execApprovalsHandlers, ...webHandlers, ...modelsHandlers, @@ -52,6 +91,11 @@ export async function handleGatewayRequest( opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers }, ): Promise { const { req, respond, client, isWebchatConnect, context } = opts; + const authError = authorizeGatewayMethod(req.method, client); + if (authError) { + respond(false, undefined, authError); + return; + } const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method]; if (!handler) { respond( diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts new file mode 100644 index 000000000..972d63335 --- /dev/null +++ b/src/gateway/server-methods/devices.ts @@ -0,0 +1,98 @@ +import { + approveDevicePairing, + listDevicePairing, + rejectDevicePairing, +} from "../../infra/device-pairing.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateDevicePairApproveParams, + validateDevicePairListParams, + validateDevicePairRejectParams, +} from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +export const deviceHandlers: GatewayRequestHandlers = { + "device.pair.list": async ({ params, respond }) => { + if (!validateDevicePairListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid device.pair.list params: ${formatValidationErrors( + validateDevicePairListParams.errors, + )}`, + ), + ); + return; + } + const list = await listDevicePairing(); + respond(true, list, undefined); + }, + "device.pair.approve": async ({ params, respond, context }) => { + if (!validateDevicePairApproveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid device.pair.approve params: ${formatValidationErrors( + validateDevicePairApproveParams.errors, + )}`, + ), + ); + return; + } + const { requestId } = params as { requestId: string }; + const approved = await approveDevicePairing(requestId); + if (!approved) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); + return; + } + context.broadcast( + "device.pair.resolved", + { + requestId, + deviceId: approved.device.deviceId, + decision: "approved", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + respond(true, approved, undefined); + }, + "device.pair.reject": async ({ params, respond, context }) => { + if (!validateDevicePairRejectParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid device.pair.reject params: ${formatValidationErrors( + validateDevicePairRejectParams.errors, + )}`, + ), + ); + return; + } + const { requestId } = params as { requestId: string }; + const rejected = await rejectDevicePairing(requestId); + if (!rejected) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); + return; + } + context.broadcast( + "device.pair.resolved", + { + requestId, + deviceId: rejected.deviceId, + decision: "rejected", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + respond(true, rejected, undefined); + }, +}; diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts new file mode 100644 index 000000000..deafc3ccc --- /dev/null +++ b/src/gateway/server-methods/exec-approval.ts @@ -0,0 +1,105 @@ +import type { ExecApprovalDecision } from "../../infra/exec-approvals.js"; +import type { ExecApprovalManager } from "../exec-approval-manager.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateExecApprovalRequestParams, + validateExecApprovalResolveParams, +} from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +export function createExecApprovalHandlers( + manager: ExecApprovalManager, +): GatewayRequestHandlers { + return { + "exec.approval.request": async ({ params, respond, context }) => { + if (!validateExecApprovalRequestParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid exec.approval.request params: ${formatValidationErrors( + validateExecApprovalRequestParams.errors, + )}`, + ), + ); + return; + } + const p = params as { + command: string; + cwd?: string; + host?: string; + security?: string; + ask?: string; + agentId?: string; + resolvedPath?: string; + sessionKey?: string; + timeoutMs?: number; + }; + const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000; + const request = { + command: p.command, + cwd: p.cwd ?? null, + host: p.host ?? null, + security: p.security ?? null, + ask: p.ask ?? null, + agentId: p.agentId ?? null, + resolvedPath: p.resolvedPath ?? null, + sessionKey: p.sessionKey ?? null, + }; + const record = manager.create(request, timeoutMs); + context.broadcast( + "exec.approval.requested", + { + id: record.id, + request: record.request, + createdAtMs: record.createdAtMs, + expiresAtMs: record.expiresAtMs, + }, + { dropIfSlow: true }, + ); + const decision = await manager.waitForDecision(record, timeoutMs); + respond(true, { + id: record.id, + decision, + createdAtMs: record.createdAtMs, + expiresAtMs: record.expiresAtMs, + }, undefined); + }, + "exec.approval.resolve": async ({ params, respond, client, context }) => { + if (!validateExecApprovalResolveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid exec.approval.resolve params: ${formatValidationErrors( + validateExecApprovalResolveParams.errors, + )}`, + ), + ); + return; + } + const p = params as { id: string; decision: string }; + const decision = p.decision as ExecApprovalDecision; + if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision")); + return; + } + const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id; + const ok = manager.resolve(p.id, decision, resolvedBy ?? null); + if (!ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id")); + return; + } + context.broadcast( + "exec.approval.resolved", + { id: p.id, decision, resolvedBy, ts: Date.now() }, + { dropIfSlow: true }, + ); + respond(true, { ok: true }, undefined); + }, + }; +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 0197fbedf..a7c406dc0 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -37,6 +37,8 @@ import { refreshGatewayHealthSnapshot, } from "./server/health-state.js"; import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js"; +import { ExecApprovalManager } from "./exec-approval-manager.js"; +import { createExecApprovalHandlers } from "./server-methods/exec-approval.js"; import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { createChannelManager } from "./server-channels.js"; import { createAgentEventHandler } from "./server-chat.js"; @@ -351,6 +353,9 @@ export async function startGatewayServer( void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`)); + const execApprovalManager = new ExecApprovalManager(); + const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager); + attachGatewayWsHandlers({ wss, clients, @@ -364,7 +369,10 @@ export async function startGatewayServer( logGateway: log, logHealth, logWsControl, - extraHandlers: pluginRegistry.gatewayHandlers, + extraHandlers: { + ...pluginRegistry.gatewayHandlers, + ...execApprovalHandlers, + }, broadcast, context: { deps, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 320ccc5f2..c0aee20c8 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -2,12 +2,24 @@ import type { IncomingMessage } from "node:http"; import os from "node:os"; import type { WebSocket } from "ws"; +import { + deriveDeviceIdFromPublicKey, + normalizeDevicePublicKeyBase64Url, + verifyDeviceSignature, +} from "../../../infra/device-identity.js"; +import { + approveDevicePairing, + getPairedDevice, + requestDevicePairing, + updatePairedDeviceMetadata, +} from "../../../infra/device-pairing.js"; import { upsertPresence } from "../../../infra/system-presence.js"; import { rawDataToString } from "../../../infra/ws.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js"; import type { ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect } from "../../auth.js"; +import { buildDeviceAuthPayload } from "../../device-auth.js"; import { isLoopbackAddress } from "../../net.js"; import { type ConnectParams, @@ -38,6 +50,8 @@ import type { GatewayWsClient } from "../ws-types.js"; type SubsystemLogger = ReturnType; +const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000; + export function attachGatewayWsMessageHandler(params: { socket: WebSocket; upgradeReq: IncomingMessage; @@ -236,6 +250,163 @@ export function attachGatewayWsMessageHandler(params: { } const authMethod = authResult.method ?? "none"; + const role = connectParams.role ?? "operator"; + const scopes = Array.isArray(connectParams.scopes) + ? connectParams.scopes + : role === "operator" + ? ["operator.admin"] + : []; + connectParams.role = role; + connectParams.scopes = scopes; + + const device = connectParams.device; + let devicePublicKey: string | null = null; + if (device) { + const derivedId = deriveDeviceIdFromPublicKey(device.publicKey); + if (!derivedId || derivedId !== device.id) { + setHandshakeState("failed"); + setCloseCause("device-auth-invalid", { + reason: "device-id-mismatch", + client: connectParams.client.id, + deviceId: device.id, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "device identity mismatch"), + }); + close(1008, "device identity mismatch"); + return; + } + const signedAt = device.signedAt; + if ( + typeof signedAt !== "number" || + Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS + ) { + setHandshakeState("failed"); + setCloseCause("device-auth-invalid", { + reason: "device-signature-stale", + client: connectParams.client.id, + deviceId: device.id, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature expired"), + }); + close(1008, "device signature expired"); + return; + } + const payload = buildDeviceAuthPayload({ + deviceId: device.id, + clientId: connectParams.client.id, + clientMode: connectParams.client.mode, + role, + scopes, + signedAtMs: signedAt, + token: connectParams.auth?.token ?? null, + }); + if (!verifyDeviceSignature(device.publicKey, payload, device.signature)) { + 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; + } + devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey); + if (!devicePublicKey) { + setHandshakeState("failed"); + setCloseCause("device-auth-invalid", { + reason: "device-public-key", + client: connectParams.client.id, + deviceId: device.id, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "device public key invalid"), + }); + close(1008, "device public key invalid"); + return; + } + } + + if (device && devicePublicKey) { + const paired = await getPairedDevice(device.id); + const isPaired = paired?.publicKey === devicePublicKey; + if (!isPaired) { + const pairing = await requestDevicePairing({ + deviceId: device.id, + publicKey: devicePublicKey, + displayName: connectParams.client.displayName, + platform: connectParams.client.platform, + clientId: connectParams.client.id, + clientMode: connectParams.client.mode, + role, + scopes, + remoteIp: remoteAddr, + silent: isLoopbackAddress(remoteAddr) && authMethod !== "none", + }); + const context = buildRequestContext(); + if (pairing.request.silent === true) { + const approved = await approveDevicePairing(pairing.request.requestId); + if (approved) { + context.broadcast( + "device.pair.resolved", + { + requestId: pairing.request.requestId, + deviceId: approved.device.deviceId, + decision: "approved", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + } + } else if (pairing.created) { + context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true }); + } + if (pairing.request.silent !== true) { + setHandshakeState("failed"); + setCloseCause("pairing-required", { + deviceId: device.id, + requestId: pairing.request.requestId, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", { + details: { requestId: pairing.request.requestId }, + }), + }); + close(1008, "pairing required"); + return; + } + } else { + await updatePairedDeviceMetadata(device.id, { + displayName: connectParams.client.displayName, + platform: connectParams.client.platform, + clientId: connectParams.client.id, + clientMode: connectParams.client.mode, + role, + scopes, + remoteIp: remoteAddr, + }); + } + } + const shouldTrackPresence = !isGatewayCliClient(connectParams.client); const clientId = connectParams.client.id; const instanceId = connectParams.client.instanceId; diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts new file mode 100644 index 000000000..611b1c632 --- /dev/null +++ b/src/infra/device-identity.ts @@ -0,0 +1,183 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type DeviceIdentity = { + deviceId: string; + publicKeyPem: string; + privateKeyPem: string; +}; + +type StoredIdentity = { + version: 1; + deviceId: string; + publicKeyPem: string; + privateKeyPem: string; + createdAtMs: number; +}; + +const DEFAULT_DIR = path.join(os.homedir(), ".clawdbot", "identity"); +const DEFAULT_FILE = path.join(DEFAULT_DIR, "device.json"); + +function ensureDir(filePath: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function base64UrlEncode(buf: Buffer): string { + return buf + .toString("base64") + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/g, ""); +} + +function base64UrlDecode(input: string): Buffer { + const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(padded, "base64"); +} + +function derivePublicKeyRaw(publicKeyPem: string): Buffer { + const key = crypto.createPublicKey(publicKeyPem); + const spki = key.export({ type: "spki", format: "der" }) as Buffer; + if ( + spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return spki.subarray(ED25519_SPKI_PREFIX.length); + } + return spki; +} + +function fingerprintPublicKey(publicKeyPem: string): string { + const raw = derivePublicKeyRaw(publicKeyPem); + return crypto.createHash("sha256").update(raw).digest("hex"); +} + +function generateIdentity(): DeviceIdentity { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const deviceId = fingerprintPublicKey(publicKeyPem); + return { deviceId, publicKeyPem, privateKeyPem }; +} + +export function loadOrCreateDeviceIdentity(filePath: string = DEFAULT_FILE): DeviceIdentity { + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as StoredIdentity; + if ( + parsed?.version === 1 && + typeof parsed.deviceId === "string" && + typeof parsed.publicKeyPem === "string" && + typeof parsed.privateKeyPem === "string" + ) { + const derivedId = fingerprintPublicKey(parsed.publicKeyPem); + if (derivedId && derivedId !== parsed.deviceId) { + const updated: StoredIdentity = { + ...parsed, + deviceId: derivedId, + }; + fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 }); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } + return { + deviceId: derivedId, + publicKeyPem: parsed.publicKeyPem, + privateKeyPem: parsed.privateKeyPem, + }; + } + return { + deviceId: parsed.deviceId, + publicKeyPem: parsed.publicKeyPem, + privateKeyPem: parsed.privateKeyPem, + }; + } + } + } catch { + // fall through to regenerate + } + + const identity = generateIdentity(); + ensureDir(filePath); + const stored: StoredIdentity = { + version: 1, + deviceId: identity.deviceId, + publicKeyPem: identity.publicKeyPem, + privateKeyPem: identity.privateKeyPem, + createdAtMs: Date.now(), + }; + fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 }); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } + return identity; +} + +export function signDevicePayload(privateKeyPem: string, payload: string): string { + const key = crypto.createPrivateKey(privateKeyPem); + const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key); + return base64UrlEncode(sig); +} + +export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | null { + try { + if (publicKey.includes("BEGIN")) { + return base64UrlEncode(derivePublicKeyRaw(publicKey)); + } + const raw = base64UrlDecode(publicKey); + return base64UrlEncode(raw); + } catch { + return null; + } +} + +export function deriveDeviceIdFromPublicKey(publicKey: string): string | null { + try { + const raw = publicKey.includes("BEGIN") + ? derivePublicKeyRaw(publicKey) + : base64UrlDecode(publicKey); + return crypto.createHash("sha256").update(raw).digest("hex"); + } catch { + return null; + } +} + +export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { + return base64UrlEncode(derivePublicKeyRaw(publicKeyPem)); +} + +export function verifyDeviceSignature( + publicKey: string, + payload: string, + signatureBase64Url: string, +): boolean { + try { + const key = publicKey.includes("BEGIN") + ? crypto.createPublicKey(publicKey) + : crypto.createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]), + type: "spki", + format: "der", + }); + const sig = (() => { + try { + return base64UrlDecode(signatureBase64Url); + } catch { + return Buffer.from(signatureBase64Url, "base64"); + } + })(); + return crypto.verify(null, Buffer.from(payload, "utf8"), key, sig); + } catch { + return false; + } +} diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts new file mode 100644 index 000000000..893412757 --- /dev/null +++ b/src/infra/device-pairing.ts @@ -0,0 +1,256 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; + +export type DevicePairingPendingRequest = { + requestId: string; + deviceId: string; + publicKey: string; + displayName?: string; + platform?: string; + clientId?: string; + clientMode?: string; + role?: string; + scopes?: string[]; + remoteIp?: string; + silent?: boolean; + isRepair?: boolean; + ts: number; +}; + +export type PairedDevice = { + deviceId: string; + publicKey: string; + displayName?: string; + platform?: string; + clientId?: string; + clientMode?: string; + role?: string; + scopes?: string[]; + remoteIp?: string; + createdAtMs: number; + approvedAtMs: number; +}; + +export type DevicePairingList = { + pending: DevicePairingPendingRequest[]; + paired: PairedDevice[]; +}; + +type DevicePairingStateFile = { + pendingById: Record; + pairedByDeviceId: Record; +}; + +const PENDING_TTL_MS = 5 * 60 * 1000; + +function resolvePaths(baseDir?: string) { + const root = baseDir ?? resolveStateDir(); + const dir = path.join(root, "devices"); + return { + dir, + pendingPath: path.join(dir, "pending.json"), + pairedPath: path.join(dir, "paired.json"), + }; +} + +async function readJSON(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function writeJSONAtomic(filePath: string, value: unknown) { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + const tmp = `${filePath}.${randomUUID()}.tmp`; + await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); + try { + await fs.chmod(tmp, 0o600); + } catch { + // best-effort + } + await fs.rename(tmp, filePath); + try { + await fs.chmod(filePath, 0o600); + } catch { + // best-effort + } +} + +function pruneExpiredPending( + pendingById: Record, + nowMs: number, +) { + for (const [id, req] of Object.entries(pendingById)) { + if (nowMs - req.ts > PENDING_TTL_MS) { + delete pendingById[id]; + } + } +} + +let lock: Promise = Promise.resolve(); +async function withLock(fn: () => Promise): Promise { + const prev = lock; + let release: (() => void) | undefined; + lock = new Promise((resolve) => { + release = resolve; + }); + await prev; + try { + return await fn(); + } finally { + release?.(); + } +} + +async function loadState(baseDir?: string): Promise { + const { pendingPath, pairedPath } = resolvePaths(baseDir); + const [pending, paired] = await Promise.all([ + readJSON>(pendingPath), + readJSON>(pairedPath), + ]); + const state: DevicePairingStateFile = { + pendingById: pending ?? {}, + pairedByDeviceId: paired ?? {}, + }; + pruneExpiredPending(state.pendingById, Date.now()); + return state; +} + +async function persistState(state: DevicePairingStateFile, baseDir?: string) { + const { pendingPath, pairedPath } = resolvePaths(baseDir); + await Promise.all([ + writeJSONAtomic(pendingPath, state.pendingById), + writeJSONAtomic(pairedPath, state.pairedByDeviceId), + ]); +} + +function normalizeDeviceId(deviceId: string) { + return deviceId.trim(); +} + +export async function listDevicePairing(baseDir?: string): Promise { + const state = await loadState(baseDir); + const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts); + const paired = Object.values(state.pairedByDeviceId).sort( + (a, b) => b.approvedAtMs - a.approvedAtMs, + ); + return { pending, paired }; +} + +export async function getPairedDevice( + deviceId: string, + baseDir?: string, +): Promise { + const state = await loadState(baseDir); + return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null; +} + +export async function requestDevicePairing( + req: Omit, + baseDir?: string, +): Promise<{ + status: "pending"; + request: DevicePairingPendingRequest; + created: boolean; +}> { + return await withLock(async () => { + const state = await loadState(baseDir); + const deviceId = normalizeDeviceId(req.deviceId); + if (!deviceId) { + throw new Error("deviceId required"); + } + const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId); + if (existing) { + return { status: "pending", request: existing, created: false }; + } + const isRepair = Boolean(state.pairedByDeviceId[deviceId]); + const request: DevicePairingPendingRequest = { + requestId: randomUUID(), + deviceId, + publicKey: req.publicKey, + displayName: req.displayName, + platform: req.platform, + clientId: req.clientId, + clientMode: req.clientMode, + role: req.role, + scopes: req.scopes, + remoteIp: req.remoteIp, + silent: req.silent, + isRepair, + ts: Date.now(), + }; + state.pendingById[request.requestId] = request; + await persistState(state, baseDir); + return { status: "pending", request, created: true }; + }); +} + +export async function approveDevicePairing( + requestId: string, + baseDir?: string, +): Promise<{ requestId: string; device: PairedDevice } | null> { + return await withLock(async () => { + const state = await loadState(baseDir); + const pending = state.pendingById[requestId]; + if (!pending) return null; + const now = Date.now(); + const existing = state.pairedByDeviceId[pending.deviceId]; + const device: PairedDevice = { + deviceId: pending.deviceId, + publicKey: pending.publicKey, + displayName: pending.displayName, + platform: pending.platform, + clientId: pending.clientId, + clientMode: pending.clientMode, + role: pending.role, + scopes: pending.scopes, + remoteIp: pending.remoteIp, + createdAtMs: existing?.createdAtMs ?? now, + approvedAtMs: now, + }; + delete state.pendingById[requestId]; + state.pairedByDeviceId[device.deviceId] = device; + await persistState(state, baseDir); + return { requestId, device }; + }); +} + +export async function rejectDevicePairing( + requestId: string, + baseDir?: string, +): Promise<{ requestId: string; deviceId: string } | null> { + return await withLock(async () => { + const state = await loadState(baseDir); + const pending = state.pendingById[requestId]; + if (!pending) return null; + delete state.pendingById[requestId]; + await persistState(state, baseDir); + return { requestId, deviceId: pending.deviceId }; + }); +} + +export async function updatePairedDeviceMetadata( + deviceId: string, + patch: Partial>, + baseDir?: string, +): Promise { + return await withLock(async () => { + const state = await loadState(baseDir); + const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)]; + if (!existing) return; + state.pairedByDeviceId[deviceId] = { + ...existing, + ...patch, + deviceId: existing.deviceId, + createdAtMs: existing.createdAtMs, + approvedAtMs: existing.approvedAtMs, + }; + await persistState(state, baseDir); + }); +}