From f65127543e111856244b29e6fedb3507ed63d79e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 08:54:36 +0000 Subject: [PATCH] fix(server): name rejected certificates when linking peers Co-authored-by: Nik <64274427+eyeveil@users.noreply.github.com> --- apps/server/src/peer/PeerLinks.test.ts | 71 +++++++++++++++++++ apps/server/src/peer/PeerLinks.ts | 57 ++++++++++++--- .../src/peer/fixtures/localhost-cert.pem | 17 +++++ .../src/peer/fixtures/localhost-key.pem | 28 ++++++++ 4 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/peer/fixtures/localhost-cert.pem create mode 100644 apps/server/src/peer/fixtures/localhost-key.pem diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 36c9b09fb4459..5198fc443f768 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - The HTTPS test server needs certificate bytes. +import * as NodeFS from "node:fs"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - A peer with a certificate this machine does not trust. +import * as NodeHttps from "node:https"; import { NodeHttpServer } from "@effect/platform-node"; import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; @@ -644,3 +648,70 @@ it.effect("linking a picked machine that answers nowhere names it and links noth }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +/** An https listener whose certificate this machine does not trust. */ +const serveUntrusted = Layer.build( + Layer.fresh( + NodeHttpServer.layerServer( + () => + NodeHttps.createServer({ + key: NodeFS.readFileSync(new URL("./fixtures/localhost-key.pem", import.meta.url)), + cert: NodeFS.readFileSync(new URL("./fixtures/localhost-cert.pem", import.meta.url)), + }), + { port: 0, host: "127.0.0.1" }, + ), + ), +).pipe( + Effect.map((server) => { + const address = Context.get(server, HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") throw new Error("expected a TCP address"); + return `https://127.0.0.1:${address.port}`; + }), +); + +it.effect("names an untrusted certificate instead of calling the peer something else", () => + Effect.scoped( + Effect.gen(function* () { + const untrusted = yield* serveUntrusted; + const a = yield* makeA(laptop); + const failed = yield* a.links + .link({ url: untrusted, pairingCode: "unused", access: "auto" }) + .pipe(Effect.flip); + expect(failed.reason).toBe("unreachable"); + expect(failed.message).toContain("certificate this machine rejected"); + expect(failed.message).toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); + expect(yield* a.links.list).toEqual([]); + + // A stored link tries its next address past an untrusted one. + const b = yield* serveB(box); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + yield* a.sql` + UPDATE peer_environment_links + SET urls_json = ${JSON.stringify([untrusted, b.url])} + WHERE environment_id = ${box.environmentId} + `; + expect((yield* a.links.resolve(box.environmentId)).url).toBe(b.url); + expect((yield* a.links.list).map((link) => link.status)).toEqual(["reachable"]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it("reads Node's TLS verification code from anywhere in a cause chain", () => { + for (const code of [ + "DEPTH_ZERO_SELF_SIGNED_CERT", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_HAS_EXPIRED", + "ERR_TLS_CERT_ALTNAME_INVALID", + "HOSTNAME_MISMATCH", + "INVALID_CA", + "INVALID_PURPOSE", + "PATH_LENGTH_EXCEEDED", + ]) { + expect(PeerLinks.certificateErrorCode({ code })).toBe(code); + } + const wrapped = { message: "fetch failed", cause: { cause: { code: "INVALID_CA" } } }; + expect(PeerLinks.certificateErrorCode(wrapped)).toBe("INVALID_CA"); + expect(PeerLinks.certificateErrorCode({ cause: { code: "ECONNREFUSED" } })).toBeUndefined(); + expect(PeerLinks.certificateErrorCode(new Error("timed out"))).toBeUndefined(); +}); diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index dfcab9f11f273..d2c8ef5fe8de3 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -5,6 +5,7 @@ import { EnvironmentHttpApi, EnvironmentId, type ExecutionEnvironmentCapabilities, + type ExecutionEnvironmentDescriptor, MCP_PEER_LINK_SOFTWARE_ID, type PeerLink, type PeerLinkCreateInput, @@ -22,6 +23,7 @@ import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -50,6 +52,23 @@ const LINK_REDIRECT_URI = "http://127.0.0.1/t3code-peer-link"; const probeTimeout = (origin: string) => isLocalLoopbackHost(new URL(origin).hostname) ? Duration.seconds(3) : Duration.seconds(10); +/** Node's TLS verification code in a failed request's cause chain, if any. */ +export const certificateErrorCode = (error: unknown) => { + let current = error; + for (let depth = 0; depth < 6 && Predicate.isObject(current); depth++) { + if ( + Predicate.hasProperty(current, "code") && + typeof current.code === "string" && + /CERT|SELF_SIGNED|UNABLE_TO_(GET|VERIFY)|INVALID_(CA|PURPOSE)|PATH_LENGTH_EXCEEDED|HOSTNAME_MISMATCH/.test( + current.code, + ) + ) + return current.code; + current = Predicate.hasProperty(current, "cause") ? current.cause : undefined; + } + return undefined; +}; + /** A usable link: where to reach the peer and the token to send there. */ export interface ResolvedPeerLink { readonly link: PeerLink; @@ -197,14 +216,38 @@ const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, peerHttpClient), ); - /** The environment answering at `origin`, or nothing when it does not answer as T3 Code. */ + /** + * The environment answering at `origin`, or nothing when it does not answer + * as T3 Code. A certificate this machine rejects fails instead, since + * the peer may well be there. + */ const describe = (origin: string) => clientFor(origin).pipe( Effect.flatMap((client) => client.metadata.descriptor()), Effect.timeout(probeTimeout(origin)), - Effect.option, + Effect.asSome, + Effect.catch((error) => { + const code = certificateErrorCode(error); + return code === undefined + ? Effect.succeed(Option.none()) + : Effect.fail( + new PeerLinkError({ + reason: "unreachable", + message: `${origin} presented a certificate this machine rejected (${code}).`, + }), + ); + }), ); + /** Whether `url` answers as `environmentId`, counting an untrusted certificate as no. */ + const answersAs = Effect.fn("PeerLinks.answersAs")(function* ( + url: string, + environmentId: EnvironmentId, + ) { + const found = yield* describe(url).pipe(Effect.orElseSucceed(() => Option.none())); + return Option.filter(found, (peer) => peer.environmentId === environmentId); + }); + const toLink = Effect.fn("PeerLinks.toLink")(function* (row: PeerLinkRow) { return { environmentId: EnvironmentId.make(row.environment_id), @@ -511,11 +554,9 @@ const make = Effect.gen(function* () { if (DateTime.isLessThanOrEqualTo(link.expiresAt, now)) return { ...link, status: "expired" }; let label = link.label; const answered = yield* Effect.findFirst(link.urls, (url) => - describe(url).pipe( + answersAs(url, link.environmentId).pipe( Effect.map((found) => { - if (Option.isNone(found) || found.value.environmentId !== link.environmentId) { - return false; - } + if (Option.isNone(found)) return false; label = found.value.label; return true; }), @@ -584,9 +625,9 @@ const make = Effect.gen(function* () { let fenced = true; let label = link.label; const url = yield* Effect.findFirst(link.urls, (candidate) => - describe(candidate).pipe( + answersAs(candidate, environmentId).pipe( Effect.map((found) => { - if (Option.isNone(found) || found.value.environmentId !== environmentId) return false; + if (Option.isNone(found)) return false; fenced = linkLimitsHold(found.value); label = found.value.label; return true; diff --git a/apps/server/src/peer/fixtures/localhost-cert.pem b/apps/server/src/peer/fixtures/localhost-cert.pem new file mode 100644 index 0000000000000..acebbe7794bcc --- /dev/null +++ b/apps/server/src/peer/fixtures/localhost-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICyTCCAbGgAwIBAgIJAOpcH/7sbrKbMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0yNjEwMjgxMzIwMDJaFw0zNjEwMjUxMzIwMDJaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBANHdJw88xLujStCPbzRi9ESeQ68eLHbOYCWEgViNAmzU4HvMINRxLtIFcIbK +E8AZ+zRauNSJTbQdvBLxYmbzmQZb9tzQZ28VqlkaXDRvExLhbJe9csxbRExcwEbN +tBXhyEztlSgJSGP+a22GYsGBljCIaOC6HtpbJo1/GBwhKlJfucGuwr2aT/njUaby +BNFaam1M44jCIqmQ1WifxMZxDzlegjKbBeis6q/2aA+EygQ4OxdjaADFj6+ZQsxm +MoEbNKni4mERPKfTM9irh8UoZDCTFHLHqTUQkzIr6dTO10ljZOLCxCmmKiyeUXH6 +Q+IFJYxXk3k5OZ+3qo1/RqOQelcCAwEAAaMeMBwwGgYDVR0RBBMwEYIJbG9jYWxo +b3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCRpA0t5fnHYhOvm7b1McZV10le +WXaJIw17ksV0BfJyZhWm2ru8udEJ6qtzSnxrHhBBHUXUJVPSJfbgV+UFcpTPnjGZ +RZzYr7Oq7K/2VyRUCRMQRv4ONo3wbh91DoN6llFiX6VYpa1kuVRscs12jPXomZ9W +Ca1jVTqr9Ty8izHCqVhNgb8xcvEONLCj8Ikeeej4cVl7gW6538UJj0LpQGv2suUR +0dAFo0tSE7KqcsPPsfdOiWWSWhy8r7unCdU/ySmIDK2/tOsQ56vkAl/0zbNVTxck +gSZOEpfqpWCDxg0txC08dQ7+Gh6MdomPXTb/Zxw9pHU3EjV14tQtcDkFa5VS +-----END CERTIFICATE----- diff --git a/apps/server/src/peer/fixtures/localhost-key.pem b/apps/server/src/peer/fixtures/localhost-key.pem new file mode 100644 index 0000000000000..58e81fe0333c3 --- /dev/null +++ b/apps/server/src/peer/fixtures/localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDR3ScPPMS7o0rQ +j280YvREnkOvHix2zmAlhIFYjQJs1OB7zCDUcS7SBXCGyhPAGfs0WrjUiU20HbwS +8WJm85kGW/bc0GdvFapZGlw0bxMS4WyXvXLMW0RMXMBGzbQV4chM7ZUoCUhj/mtt +hmLBgZYwiGjguh7aWyaNfxgcISpSX7nBrsK9mk/541Gm8gTRWmptTOOIwiKpkNVo +n8TGcQ85XoIymwXorOqv9mgPhMoEODsXY2gAxY+vmULMZjKBGzSp4uJhETyn0zPY +q4fFKGQwkxRyx6k1EJMyK+nUztdJY2TiwsQppiosnlFx+kPiBSWMV5N5OTmft6qN +f0ajkHpXAgMBAAECggEBAMrmKBdhR0uqinCt3HQnn8afxQyP/ak3ylk7jNqSKmAS +SJ8oMi1y6DOBstN+mC3IcMijVcOWpBuaC/W8jsi+h5pQW5INo5MQvUu8Yp0HLse4 +Toc+ULknHBdALV3HD2+AhWlq4iR9JRIYLsELhntT0GNH3y42hBHUwqdW03aO0h4k +UHPDtXBA0qWEwLUxtDt6dMWblaBc679FVpQflmj1zjVvcir8hgU5jE8B52zXo0AW +2QtrKZe0ND1TwCmAg61Ox2mZ1MUBFR6pLuKAwTpob6lFoN9OV1x5QETEN6Cz8/db +sgEw9YbVLMY3Gpk379POTd8Bq+CG7ryjdXm6voJDhUECgYEA/wXcR1dv4FLG+i52 +utGL2Uc2B4iLzGkVpzm49mpI8yYr7pJ/PBNgkHLEwLzMp6Rybm5tbEWdK3EF+A+b +mZ+KeO6rLcc6QityvkhXL3QfmVX0QFA0DDYRVMJw99fLJgVsR+5FU+angqD/zskp +jKS+iH4CKk3DINbalsMPpHwUmnECgYEA0qr/cgisNtpqpC32CPQ/ud3Oq7pSQ0tL +8S1dmJ0wJzaLFaXZ3Pofgd81S9Lx/ZvTsYQveO17+h/aZyKzWk5YC5smIPdCdyln +iiVu7Ep/n+5njIuxDlzqMvpoIEcyEnYO2SfWiCP8oLpVVnMNn21QbsPp4RvOVuS7 +jPZBin2EdUcCgYAx/8MlVZZAo9oRGOT8UbMagWSta28FP6EYLS//V+0/hr9cQeqi +K6ZmC6pZo2p4f7xTvGDFtoxPBRTHgr/eCbKLbTewfPwGFyCi/+ngCuNbG8gMh+z8 +a+lP9eNSJEPXC38kRynefj1fVhNxRm1X/sDAX/wqsR5NpPIsVsgNVqpvcQKBgQCq +Cj+5KWrns5BUCI0IWS1+tpv0XKW9j28yse1UG7wcSpgk1wNsq672wtVf6FpO25IZ +PJqcvwNMtp0wlf+yKn5Cj+5MuYc6a38ZMHHB47fzvRhzhuVYtIKMtzWIqC6wW/5M +JS2yIWntybjL2H3sau17GkMS4cDAvkrr0nDAeeOKTQKBgHJKIpkP7fjowXIaO70N +OqajkGxaeuhhfGPLJlmroi15hlijNiTBNtcMzm8K+Wo5kHDp4efG+2As8bS3cXBW +arc3zQVEqEEi9dluVfWc1yb2jeUw+W+b+ZohZqAkIih6Fb3wllk522zRDQLY0Yz9 +yGsIbsE2QjJ+w5GzVul7zaix +-----END PRIVATE KEY-----