diff --git a/src/gateway/client.ts b/src/gateway/client.ts index f6bd22b1a..374420e19 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { WebSocket, type ClientOptions, type CertMeta } from "ws"; +import { normalizeFingerprint } from "../infra/tls/fingerprint.js"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; @@ -99,6 +100,10 @@ export class GatewayClient { start() { if (this.closed) return; const url = this.opts.url ?? "ws://127.0.0.1:18789"; + if (this.opts.tlsFingerprint && !url.startsWith("wss://")) { + this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url")); + return; + } // Allow node screen snapshots and other large responses. const wsOptions: ClientOptions = { maxPayload: 25 * 1024 * 1024, @@ -399,7 +404,3 @@ export class GatewayClient { return p; } } - -function normalizeFingerprint(input: string): string { - return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase(); -} diff --git a/src/gateway/server-broadcast.test.ts b/src/gateway/server-broadcast.test.ts new file mode 100644 index 000000000..44d164cf0 --- /dev/null +++ b/src/gateway/server-broadcast.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createGatewayBroadcaster } from "./server-broadcast.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; + +type TestSocket = { + bufferedAmount: number; + send: (payload: string) => void; + close: (code: number, reason: string) => void; +}; + +describe("gateway broadcaster", () => { + it("filters approval and pairing events by scope", () => { + const approvalsSocket: TestSocket = { + bufferedAmount: 0, + send: vi.fn(), + close: vi.fn(), + }; + const pairingSocket: TestSocket = { + bufferedAmount: 0, + send: vi.fn(), + close: vi.fn(), + }; + const readSocket: TestSocket = { + bufferedAmount: 0, + send: vi.fn(), + close: vi.fn(), + }; + + const clients = new Set([ + { + socket: approvalsSocket as unknown as GatewayWsClient["socket"], + connect: { role: "operator", scopes: ["operator.approvals"] } as GatewayWsClient["connect"], + connId: "c-approvals", + }, + { + socket: pairingSocket as unknown as GatewayWsClient["socket"], + connect: { role: "operator", scopes: ["operator.pairing"] } as GatewayWsClient["connect"], + connId: "c-pairing", + }, + { + socket: readSocket as unknown as GatewayWsClient["socket"], + connect: { role: "operator", scopes: ["operator.read"] } as GatewayWsClient["connect"], + connId: "c-read", + }, + ]); + + const { broadcast } = createGatewayBroadcaster({ clients }); + + broadcast("exec.approval.requested", { id: "1" }); + broadcast("device.pair.requested", { requestId: "r1" }); + + expect(approvalsSocket.send).toHaveBeenCalledTimes(1); + expect(pairingSocket.send).toHaveBeenCalledTimes(1); + expect(readSocket.send).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index de4ca3aa7..61df31097 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -2,6 +2,29 @@ import type { GatewayWsClient } from "./server/ws-types.js"; import { MAX_BUFFERED_BYTES } from "./server-constants.js"; import { logWs, summarizeAgentEventForWsLog } from "./ws-log.js"; +const ADMIN_SCOPE = "operator.admin"; +const APPROVALS_SCOPE = "operator.approvals"; +const PAIRING_SCOPE = "operator.pairing"; + +const EVENT_SCOPE_GUARDS: Record = { + "exec.approval.requested": [APPROVALS_SCOPE], + "exec.approval.resolved": [APPROVALS_SCOPE], + "device.pair.requested": [PAIRING_SCOPE], + "device.pair.resolved": [PAIRING_SCOPE], + "node.pair.requested": [PAIRING_SCOPE], + "node.pair.resolved": [PAIRING_SCOPE], +}; + +function hasEventScope(client: GatewayWsClient, event: string): boolean { + const required = EVENT_SCOPE_GUARDS[event]; + if (!required) return true; + const role = client.connect.role ?? "operator"; + if (role !== "operator") return false; + const scopes = Array.isArray(client.connect.scopes) ? client.connect.scopes : []; + if (scopes.includes(ADMIN_SCOPE)) return true; + return required.some((scope) => scopes.includes(scope)); +} + export function createGatewayBroadcaster(params: { clients: Set }) { let seq = 0; const broadcast = ( @@ -33,6 +56,7 @@ export function createGatewayBroadcaster(params: { clients: Set } logWs("out", "event", logMeta); for (const c of params.clients) { + if (!hasEventScope(c, event)) continue; const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES; if (slow && opts?.dropIfSlow) continue; if (slow) { diff --git a/src/infra/tls/fingerprint.test.ts b/src/infra/tls/fingerprint.test.ts new file mode 100644 index 000000000..f1412d747 --- /dev/null +++ b/src/infra/tls/fingerprint.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeFingerprint } from "./fingerprint.js"; + +describe("normalizeFingerprint", () => { + it("strips sha256 prefixes and separators", () => { + expect(normalizeFingerprint("sha256:AA:BB:cc")).toBe("aabbcc"); + expect(normalizeFingerprint("SHA-256 11-22-33")).toBe("112233"); + expect(normalizeFingerprint("aa:bb:cc")).toBe("aabbcc"); + }); +}); diff --git a/src/infra/tls/fingerprint.ts b/src/infra/tls/fingerprint.ts new file mode 100644 index 000000000..1138d21f5 --- /dev/null +++ b/src/infra/tls/fingerprint.ts @@ -0,0 +1,5 @@ +export function normalizeFingerprint(input: string): string { + const trimmed = input.trim(); + const withoutPrefix = trimmed.replace(/^sha-?256\s*:?\s*/i, ""); + return withoutPrefix.replace(/[^a-fA-F0-9]/g, "").toLowerCase(); +} diff --git a/src/infra/tls/gateway.ts b/src/infra/tls/gateway.ts index 00e6da46c..e02a0433a 100644 --- a/src/infra/tls/gateway.ts +++ b/src/infra/tls/gateway.ts @@ -7,6 +7,7 @@ import { promisify } from "node:util"; import type { GatewayTlsConfig } from "../../config/types.gateway.js"; import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js"; +import { normalizeFingerprint } from "./fingerprint.js"; const execFileAsync = promisify(execFile); @@ -21,10 +22,6 @@ export type GatewayTlsRuntime = { error?: string; }; -function normalizeFingerprint(input: string): string { - return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase(); -} - async function fileExists(filePath: string): Promise { try { await fs.access(filePath);