fix: validate ws tls fingerprint

This commit is contained in:
Peter Steinberger
2026-01-20 12:31:09 +00:00
parent 06c17a333e
commit dcb8d16591
5 changed files with 227 additions and 3 deletions

View File

@@ -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.

View File

@@ -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<number> {
describe("GatewayClient", () => {
let wss: WebSocketServer | null = null;
let httpsServer: ReturnType<typeof createHttpsServer> | null = null;
afterEach(async () => {
if (wss) {
for (const client of wss.clients) {
client.terminate();
}
await new Promise<void>((resolve) => wss?.close(() => resolve()));
wss = null;
}
if (httpsServer) {
httpsServer.closeAllConnections?.();
httpsServer.closeIdleConnections?.();
await new Promise<void>((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<number>((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<Error>((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");
});
});

View File

@@ -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<T = unknown>(
method: string,
params?: unknown,

View File

@@ -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);
});
});

View File

@@ -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"]));
});
});