diff --git a/docs/refactor/clawnet.md b/docs/refactor/clawnet.md index 05301b7eb..ce1d440a7 100644 --- a/docs/refactor/clawnet.md +++ b/docs/refactor/clawnet.md @@ -296,8 +296,8 @@ Same `deviceId` across roles → single “Instance” row: - [x] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation. - [x] **Discovery + allowlist:** WS discovery TXT includes TLS fingerprint + role hints; node commands filtered by server allowlist. - [x] **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. +- [x] **Docs + examples:** protocol doc, CLI docs, onboarding + security notes; no personal hostnames. +- [x] **Test coverage:** connect auth paths, rotation/revoke, approvals, TLS fingerprint mismatch, presence. Process per item: - Do implementation. diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 19e12bef3..00fadfa2f 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -1,4 +1,5 @@ import { createServer } from "node:net"; +import { createServer as createHttpsServer } from "node:https"; import { afterEach, describe, expect, test } from "vitest"; import { WebSocketServer } from "ws"; import { rawDataToString } from "../infra/ws.js"; @@ -17,12 +18,22 @@ async function getFreePort(): Promise { describe("GatewayClient", () => { let wss: WebSocketServer | null = null; + let httpsServer: ReturnType | null = null; afterEach(async () => { if (wss) { + for (const client of wss.clients) { + client.terminate(); + } await new Promise((resolve) => wss?.close(() => resolve())); wss = null; } + if (httpsServer) { + httpsServer.closeAllConnections?.(); + httpsServer.closeIdleConnections?.(); + await new Promise((resolve) => httpsServer?.close(() => resolve())); + httpsServer = null; + } }); test("closes on missing ticks", async () => { @@ -67,4 +78,99 @@ describe("GatewayClient", () => { expect(res.code).toBe(4000); expect(res.reason).toContain("tick timeout"); }, 4000); + + test("rejects mismatched tls fingerprint", async () => { + const key = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb +DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ +Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk +UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1 +EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s +XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr +FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt +KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval +YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9 +KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl +vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm +MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+ +fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+ +iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh +bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn +aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/ +LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK +gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j +4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+ +42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj +7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2 +bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD +ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy +l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq +YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O +++pfnSCVCyp/TxSkhEDEawU= +-----END PRIVATE KEY-----`; + const cert = `-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUel0Lv05cjrViyI/H3tABBJxM7NgwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDEyMDEyMjEzMloXDTI2MDEy +MTEyMjEzMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA67q+QlqeKbDDGw0z2NWjeOhzw8UXIRoIfF3nTZK5XOM9 +ShYsi1LF6VSIbsqF6tX35aUw8+/vqRhAyUOaRHQoZ937loIu4Avqb3eVUNXgF/+6 +lRO9n4cdeDcYWomVN4Qs14xtkn5UxBBMZFJEE5tK3R0o4C1TIUzNz6puis33YLZv +Wcl8JQLKKxP6b4G1MRt0OMSjQRs24q2ftRMzw8LI3934rTbWpGSZMpruioOZbFIo +UFVzj9FO3/fPRZnr6EzLyZpLyc7KE0Xe7FzUjo8zsCa/HWvAuB5F4ttZndchHHMl +tIkoe7Vrw66VgwIFukTLjBwtLVuG5KQxqxaW0DoM1QIDAQABo1MwUTAdBgNVHQ4E +FgQUwNdNkEQtd0n/aofzN7/EeYPPPbIwHwYDVR0jBBgwFoAUwNdNkEQtd0n/aofz +N7/EeYPPPbIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAnOnw +o8Az/bL0A6bGHTYra3L9ArIIljMajT6KDHxylR4LhliuVNAznnhP3UkcZbUdjqjp +MNOM0lej2pNioondtQdXUskZtqWy6+dLbTm1RYQh1lbCCZQ26o7o/oENzjPksLAb +jRM47DYxRweTyRWQ5t9wvg/xL0Yi1tWq4u4FCNZlBMgdwAEnXNwVWTzRR9RHwy20 +lmUzM8uQ/p42bk4EvPEV4PI1h5G0khQ6x9CtkadCTDs/ZqoUaJMwZBIDSrdJJSLw +4Vh8Lqzia1CFB4um9J4S1Gm/VZMBjjeGGBJk7VSYn4ZmhPlbPM+6z39lpQGEG0x4 +r1USnb+wUdA7Zoj/mQ== +-----END CERTIFICATE-----`; + + httpsServer = createHttpsServer({ key, cert }); + wss = new WebSocketServer({ server: httpsServer, maxPayload: 1024 * 1024 }); + const port = await new Promise((resolve, reject) => { + httpsServer?.once("error", reject); + httpsServer?.listen(0, "127.0.0.1", () => { + const address = httpsServer?.address(); + if (!address || typeof address === "string") { + reject(new Error("https server address unavailable")); + return; + } + resolve(address.port); + }); + }); + + let client: GatewayClient | null = null; + const error = await new Promise((resolve) => { + let settled = false; + const finish = (err: Error) => { + if (settled) return; + settled = true; + resolve(err); + }; + const timeout = setTimeout(() => { + client?.stop(); + finish(new Error("timeout waiting for tls error")); + }, 2000); + client = new GatewayClient({ + url: `wss://127.0.0.1:${port}`, + tlsFingerprint: "deadbeef", + onConnectError: (err) => { + clearTimeout(timeout); + client?.stop(); + finish(err); + }, + onClose: () => { + clearTimeout(timeout); + client?.stop(); + finish(new Error("closed without tls error")); + }, + }); + client.start(); + }); + + expect(String(error)).toContain("tls fingerprint mismatch"); + }); }); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index df0794c15..f6bd22b1a 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -128,7 +128,17 @@ export class GatewayClient { } this.ws = new WebSocket(url, wsOptions); - this.ws.on("open", () => this.queueConnect()); + this.ws.on("open", () => { + if (url.startsWith("wss://") && this.opts.tlsFingerprint) { + const tlsError = this.validateTlsFingerprint(); + if (tlsError) { + this.opts.onConnectError?.(tlsError); + this.ws?.close(1008, tlsError.message); + return; + } + } + this.queueConnect(); + }); this.ws.on("message", (data) => this.handleMessage(rawDataToString(data))); this.ws.on("close", (code, reason) => { const reasonText = rawDataToString(reason); @@ -139,6 +149,9 @@ export class GatewayClient { }); this.ws.on("error", (err) => { logDebug(`gateway client error: ${String(err)}`); + if (!this.connectSent) { + this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err))); + } }); } @@ -340,6 +353,25 @@ export class GatewayClient { }, interval); } + private validateTlsFingerprint(): Error | null { + if (!this.opts.tlsFingerprint || !this.ws) return null; + const expected = normalizeFingerprint(this.opts.tlsFingerprint); + if (!expected) return new Error("gateway tls fingerprint missing"); + const socket = ( + this.ws as WebSocket & { + _socket?: { getPeerCertificate?: () => { fingerprint256?: string } }; + } + )._socket; + if (!socket || typeof socket.getPeerCertificate !== "function") { + return new Error("gateway tls fingerprint unavailable"); + } + const cert = socket.getPeerCertificate(); + const fingerprint = normalizeFingerprint(cert?.fingerprint256 ?? ""); + if (!fingerprint) return new Error("gateway tls fingerprint unavailable"); + if (fingerprint !== expected) return new Error("gateway tls fingerprint mismatch"); + return null; + } + async request( method: string, params?: unknown, diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts new file mode 100644 index 000000000..1d0328532 --- /dev/null +++ b/src/gateway/server-methods/exec-approval.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExecApprovalManager } from "../exec-approval-manager.js"; +import { createExecApprovalHandlers } from "./exec-approval.js"; + +const noop = () => {}; + +describe("exec approval handlers", () => { + it("broadcasts request + resolve", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const broadcasts: Array<{ event: string; payload: unknown }> = []; + + const respond = vi.fn(); + const context = { + broadcast: (event: string, payload: unknown) => { + broadcasts.push({ event, payload }); + }, + }; + + const requestPromise = handlers["exec.approval.request"]({ + params: { + command: "echo ok", + cwd: "/tmp", + host: "node", + timeoutMs: 2000, + }, + respond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.request"] + >[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "exec.approval.request" }, + isWebchatConnect: noop, + }); + + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + expect(requested).toBeTruthy(); + const id = (requested?.payload as { id?: string })?.id ?? ""; + expect(id).not.toBe(""); + + const resolveRespond = vi.fn(); + await handlers["exec.approval.resolve"]({ + params: { id, decision: "allow-once" }, + respond: resolveRespond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.resolve"] + >[0]["context"], + client: { connect: { client: { id: "cli", displayName: "CLI" } } }, + req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, + isWebchatConnect: noop, + }); + + await requestPromise; + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id, decision: "allow-once" }), + undefined, + ); + expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); + }); +}); diff --git a/src/infra/system-presence.test.ts b/src/infra/system-presence.test.ts index e75e164d5..d07c2d0a4 100644 --- a/src/infra/system-presence.test.ts +++ b/src/infra/system-presence.test.ts @@ -33,4 +33,27 @@ describe("system-presence", () => { expect(matches[0]?.ip).toBe("10.0.0.1"); expect(matches[0]?.lastInputSeconds).toBe(5); }); + + it("merges roles and scopes for the same device", () => { + const deviceId = randomUUID(); + + upsertPresence(deviceId, { + deviceId, + host: "clawdbot", + roles: ["operator"], + scopes: ["operator.admin"], + reason: "connect", + }); + + upsertPresence(deviceId, { + deviceId, + roles: ["node"], + scopes: ["system.run"], + reason: "connect", + }); + + const entry = listSystemPresence().find((e) => e.deviceId === deviceId); + expect(entry?.roles).toEqual(expect.arrayContaining(["operator", "node"])); + expect(entry?.scopes).toEqual(expect.arrayContaining(["operator.admin", "system.run"])); + }); });