From 584c70e16e82a7d48b26d49bf7b14dc6e059ca9b Mon Sep 17 00:00:00 2001 From: karup <8500529+karup@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:54:57 -0700 Subject: [PATCH 1/4] feat(mcp): support per-server TLS trust for remote MCP servers Remote MCP servers can now carry a tls config with a CA file, inline CA PEM, or a pinned SHA256 certificate fingerprint, scoping trust to that server only. System verification is unchanged for everything else. --- packages/core/src/mcp/client.ts | 8 +- packages/core/src/mcp/tls.ts | 198 +++++++++ packages/core/test/fixture/tls-server.ts | 37 ++ packages/core/test/fixture/tls/localhost.crt | 19 + packages/core/test/fixture/tls/localhost.key | 28 ++ packages/core/test/mcp-tls.test.ts | 389 ++++++++++++++++++ packages/schema/src/config/mcp.ts | 2 + packages/schema/src/mcp.ts | 18 + packages/schema/test/mcp.test.ts | 24 ++ services/www/src/docs/content/mcp-servers.mdx | 57 +++ 10 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/mcp/tls.ts create mode 100644 packages/core/test/fixture/tls-server.ts create mode 100644 packages/core/test/fixture/tls/localhost.crt create mode 100644 packages/core/test/fixture/tls/localhost.key create mode 100644 packages/core/test/mcp-tls.test.ts diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index c8d580291a8d..85931abddeb9 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -314,16 +314,22 @@ export const connect = Effect.fnUntraced(function* ( return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js")) const fetch = yield* McpOAuth.loggedFetch({ server, directory }) + const { McpTls } = yield* Effect.promise(() => import("./tls.js")) // Servers that bundle their own Code Mode (Cloudflare and others) expose raw tools when asked // with ?codemode=false, which is what our Code Mode wants. The configured URL stays the OAuth identity. const url = new URL(config.url) const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode") && !rejectsUnknownQuery(url) if (addedCodemode) url.searchParams.set("codemode", "false") + // A missing CA file or a fingerprint mismatch fails the connect instead of the first request. + const tls = config.tls + const ca = tls + ? yield* Effect.tryPromise({ try: () => McpTls.buildTlsCa(tls, directory, url), catch: toError }) + : "" const open = Effect.fnUntraced(function* (url: URL) { session.transport = new StreamableHTTPClientTransport(url, { requestInit: config.headers ? { headers: config.headers } : undefined, authProvider, - fetch, + fetch: ca ? McpTls.withTlsCa(ca, fetch) : fetch, }) return yield* initialize(session.transport) }) diff --git a/packages/core/src/mcp/tls.ts b/packages/core/src/mcp/tls.ts new file mode 100644 index 000000000000..652b025ac956 --- /dev/null +++ b/packages/core/src/mcp/tls.ts @@ -0,0 +1,198 @@ +export * as McpTls from "./tls.js" + +import { closeSync, fstatSync, openSync, readFileSync } from "node:fs" +import { isIP } from "node:net" +import { homedir } from "node:os" +import path from "node:path" +import tls from "node:tls" +import type { ConfigMCP } from "@opencode/schema/config/mcp" + +const PEM_CERT_HEADER = "-----BEGIN CERTIFICATE-----" +const PEM_CERT_FOOTER = "-----END CERTIFICATE-----" +const MAX_CA_FILE_SIZE = 1024 * 1024 +const FINGERPRINT_PATTERN = /^(?:SHA256:)?(?:[0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$|^(?:SHA256:)?[0-9A-Fa-f]{64}$/ + +/** + * Resolve a CA file path: `~` expands to the home directory, absolute paths pass through, + * and relative paths resolve from the workspace directory. + */ +export function resolveFilePath(filePath: string, workspaceDir: string): string { + if (filePath.startsWith("~")) return path.join(homedir(), filePath.slice(2)) + if (path.isAbsolute(filePath)) return filePath + return path.resolve(workspaceDir, filePath) +} + +/** + * Validate that content holds at least one well-formed PEM certificate block. Cryptographic + * validation is the TLS layer's job at connection time; certificates may be concatenated, so + * only the first BEGIN/END pair must be complete. + */ +export function validatePemCert(content: string, source: string): void { + const beginIdx = content.indexOf(PEM_CERT_HEADER) + if (beginIdx === -1) throw new Error(`${source}: missing PEM certificate header ("${PEM_CERT_HEADER}")`) + const endIdx = content.indexOf(PEM_CERT_FOOTER, beginIdx) + if (endIdx === -1) throw new Error(`${source}: missing PEM certificate footer ("${PEM_CERT_FOOTER}")`) + if (content.slice(beginIdx + PEM_CERT_HEADER.length, endIdx).trim().length === 0) + throw new Error(`${source}: empty PEM certificate body`) +} + +/** + * Validate a SHA256 fingerprint and normalize it to lowercase hex without prefix or colons. + */ +export function validateFingerprint(raw: string): string { + const trimmed = raw.trim() + if (!FINGERPRINT_PATTERN.test(trimmed)) + throw new Error( + `Invalid SHA256 fingerprint: "${trimmed}". Expected 64 hex characters, ` + + `optionally prefixed with "SHA256:" and separated by colons (e.g. "SHA256:AA:BB:CC:DD:...").`, + ) + return trimmed.replace(/^SHA256:/i, "").replace(/:/g, "").toLowerCase() +} + +/** + * Read and validate a CA certificate file. The file is opened once and the descriptor is used + * for both stat and read, so it cannot change between validation and content retrieval. + */ +export function readCaFile(filePath: string, workspaceDir: string): string { + const resolved = resolveFilePath(filePath, workspaceDir) + let fd: number + try { + fd = openSync(resolved, "r") + } catch { + throw new Error(`CA file not found: ${filePath}`) + } + try { + const stat = fstatSync(fd) + if (!stat.isFile()) throw new Error(`CA path is not a file: ${filePath}`) + if (stat.size > MAX_CA_FILE_SIZE) + throw new Error(`CA file too large (${stat.size} bytes, max ${MAX_CA_FILE_SIZE}): ${filePath}`) + const content = readFileSync(fd, "utf-8").trim() + validatePemCert(content, filePath) + return content + } finally { + closeSync(fd) + } +} + +/** + * Build the PEM CA bundle for a server's TLS config. An empty result means "use the system + * trust store". A pinned fingerprint opens one pre-flight TLS connection; its certificate is + * verified and returned as the trust anchor for the real connection. + */ +export async function buildTlsCa(config: ConfigMCP.Tls, workspaceDir: string, url: URL): Promise { + if (config.ca_pem !== undefined) validatePemCert(config.ca_pem, "ca_pem config entry") + const pinned = config.fingerprint !== undefined ? await pinFingerprint(config.fingerprint, url) : undefined + return [ + config.ca_pem, + config.ca_file !== undefined ? readCaFile(config.ca_file, workspaceDir) : undefined, + pinned, + ] + .filter((part): part is string => part !== undefined) + .join("") +} + +/** + * Wrap a fetch so its requests trust the given CA. The trust is scoped to the returned fetch + * and does not affect other connections, including redirects it follows. + */ +export function withTlsCa( + ca: string, + base: (url: string | URL, init?: RequestInit) => Promise, +): (url: string | URL, init?: RequestInit) => Promise { + return (url, init) => base(url, { ...init, tls: { ca } } as BunFetchRequestInit) +} + +function pinFingerprint(raw: string, url: URL): Promise { + const expected = validateFingerprint(raw) + return verifyServerFingerprint(url.hostname, port(url), expected) +} + +/** + * Open a raw TLS socket, compare the presented certificate's SHA256 fingerprint, and return the + * certificate as PEM. This is the only place verification is relaxed (rejectUnauthorized: false), + * and only to read the certificate; the socket is destroyed before any application data is sent. + */ +function verifyServerFingerprint(hostname: string, portNumber: number, expectedFingerprint: string): Promise { + return probeCertificate(hostname, portNumber).then((cert) => { + if (cert.fingerprint !== expectedFingerprint) + throw new Error( + `Certificate fingerprint mismatch.\nExpected: ${expectedFingerprint}\nGot: ${cert.fingerprint}`, + ) + return cert.pem + }) +} + +export interface Certificate { + /** The presented certificate as PEM, usable as a trust anchor. */ + readonly pem: string + /** SHA256 fingerprint, lowercase hex without colons. */ + readonly fingerprint: string + /** Distinguished name of the certificate subject, e.g. "CN=OPNsense, O=Foo Corp". */ + readonly subject: string + /** Expiry as epoch milliseconds. */ + readonly validTo: number +} + +/** + * Read the certificate a server presents without validating it against any trust store. The + * socket is destroyed before any application data is sent. + */ +export function probeCertificate(hostname: string, portNumber: number): Promise { + return new Promise((resolve, reject) => { + // SNI rejects IP literals as server names, but LAN appliances are exactly the servers this + // feature targets, so omit the server name for IP addresses. + const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname + const socket = tls.connect({ + host: hostname, + port: portNumber, + rejectUnauthorized: false, + ...(isIP(bare) === 0 ? { servername: hostname } : {}), + }) + const fail = (message: string) => { + socket.destroy() + reject(new Error(message)) + } + socket.once("secureConnect", () => { + const cert = socket.getPeerX509Certificate() + if (!cert) return fail("No certificate presented by server") + const presented = cert.fingerprint256 + if (!presented) return fail("Could not compute certificate fingerprint") + const validTo = new Date(cert.validTo).getTime() + if (Number.isNaN(validTo)) return fail("Could not parse certificate validity") + socket.destroy() + resolve({ + pem: derToPem(cert.raw), + fingerprint: presented.replace(/:/g, "").toLowerCase(), + subject: cert.subject, + validTo, + }) + }) + socket.once("error", (err: Error) => { + socket.destroy() + reject(new Error(`TLS pre-flight connection failed: ${err.message}`)) + }) + socket.setTimeout(10_000, () => { + socket.destroy() + reject(new Error("TLS pre-flight connection timed out")) + }) + }) +} + +/** + * The port of a server URL, with the scheme default when none is stated. + */ +export function port(url: URL): number { + return Number(url.port) || (url.protocol === "https:" ? 443 : 80) +} + +/** + * Convert a DER certificate to PEM so it can be used as a trust anchor. Bytes are base64-encoded + * in chunks to keep the spread argument small on large certificates. + */ +export function derToPem(der: ArrayBuffer): string { + const bytes = new Uint8Array(der) + const chunks: string[] = [] + for (let i = 0; i < bytes.length; i += 4096) chunks.push(btoa(String.fromCharCode(...bytes.subarray(i, i + 4096)))) + const lines = chunks.join("").match(/.{1,64}/g) ?? [] + return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----\n` +} diff --git a/packages/core/test/fixture/tls-server.ts b/packages/core/test/fixture/tls-server.ts new file mode 100644 index 000000000000..ce8bd4e370ab --- /dev/null +++ b/packages/core/test/fixture/tls-server.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs" +import path from "node:path" +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" + +export const TLS_FIXTURE_DIR = path.join(import.meta.dir, "tls") + +// Long-lived self-signed certificate for 127.0.0.1 and localhost. +export const CERT = readFileSync(path.join(TLS_FIXTURE_DIR, "localhost.crt"), "utf-8") +export const KEY = readFileSync(path.join(TLS_FIXTURE_DIR, "localhost.key"), "utf-8") + +export function startTlsMcpServer(options: { cert?: string; key?: string; port?: number } = {}) { + const cert = options.cert ?? CERT + const key = options.key ?? KEY + const protocol = new Server({ name: "tls-test", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler("tools/list", () => + Promise.resolve({ tools: [{ name: "echo", inputSchema: { type: "object", properties: {} } }] }), + ) + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() }) + return protocol.connect(transport).then(() => { + const http = Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert, key }, + fetch: (request) => transport.handleRequest(request), + }) + const port = http.port + if (port === undefined) throw new Error("TLS server did not bind a port") + return { + url: `${http.url}/mcp`, + port, + close: async () => { + await protocol.close().catch(() => {}) + http.stop(true) + }, + } + }) +} diff --git a/packages/core/test/fixture/tls/localhost.crt b/packages/core/test/fixture/tls/localhost.crt new file mode 100644 index 000000000000..08a7c33aa30c --- /dev/null +++ b/packages/core/test/fixture/tls/localhost.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUTKIB22rVLojTO840h8mzTuRnOxIwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyODA4MzQ0N1oXDTM2MTAy +NTA4MzQ0N1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA27Z4pxp0GVVWzwFQT4I+AGNb13g8tRSoOK7hvZ7TFH2v +kcBC+N7rrdlH/5a2lFGdoFKV0Mo2eV1Sge4pHqohzeadCSc/aYQEUJc6hmHVmZ8q +OIgSZJU/JsMRr7J8cBOm9saulzJh2KIqiRnqukBScob3A8ILwQXCbi/cHJIXV2tc +f/Lum7g9QPJvahQ1zOVUuasiYrKwaeKI22WoBnYKJQbXTx53vJPsCtRVl4sSCkFK +Bw08KlLAgxm3iMF/W1UgwLQNRCVwCzHg3IMM7Cufk/ZdYrq79W+dPQtO1hSwXtx/ +cNzEM33+6HxMQbJqt0SD6f7fIGFTtqhLEd6RoZTzgwIDAQABo28wbTAdBgNVHQ4E +FgQU5yrZI6/BOM0LRqzxe/8i28xbj1gwHwYDVR0jBBgwFoAU5yrZI6/BOM0LRqzx +e/8i28xbj1gwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh +bGhvc3QwDQYJKoZIhvcNAQELBQADggEBALGiM6XviFYv4a75Om8rPGFiLD9DNPe0 +Rxr0gOpaMvXheJQTMFkrM8sBqt0Y8gL2JT8ZYcEKXZSlh6n0cGvh5wyPsvsNA7A1 +kABIfh0Yf2Znp2E/mgpF4kdRWgbRvv+77llTBJNdBViGIWNHzZH92q1UGWPs9JKD +sttvK02rP+Xt7GXqMERwlAvAC0RZ6RpV46QfG0SuKCnV369WQRDt5BtilRaePnFk +U4dq7nK2dq+rWu6OuysOQHyeEP7FBhxdQlTnpjXnOmAjcZihfm/Hl2cvubhlvBM5 +yfO7vILtaXtrd82xBmUGOSzuRtBPwlsbXkhz/pupxJZtC4xhO9t+Qgo= +-----END CERTIFICATE----- diff --git a/packages/core/test/fixture/tls/localhost.key b/packages/core/test/fixture/tls/localhost.key new file mode 100644 index 000000000000..f984539aa644 --- /dev/null +++ b/packages/core/test/fixture/tls/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDbtninGnQZVVbP +AVBPgj4AY1vXeDy1FKg4ruG9ntMUfa+RwEL43uut2Uf/lraUUZ2gUpXQyjZ5XVKB +7ikeqiHN5p0JJz9phARQlzqGYdWZnyo4iBJklT8mwxGvsnxwE6b2xq6XMmHYoiqJ +Geq6QFJyhvcDwgvBBcJuL9wckhdXa1x/8u6buD1A8m9qFDXM5VS5qyJisrBp4ojb +ZagGdgolBtdPHne8k+wK1FWXixIKQUoHDTwqUsCDGbeIwX9bVSDAtA1EJXALMeDc +gwzsK5+T9l1iurv1b509C07WFLBe3H9w3MQzff7ofExBsmq3RIPp/t8gYVO2qEsR +3pGhlPODAgMBAAECggEAK4b1VSmc+Y3aEk2Q0wea7+m/Pqsv37H1d7FRwIawC52w +xB/CRNqo9zqWFHEPv8eBgksKKiB9dULDP0Fm/Q+4fbmjv7X6TpWcYzjqm8fTEMmj +DCsM9cdJiQzZOqUSGQQTrk0DNqoBD3FlGt87DOYET3MkiaaOPJG/CKRLp49bt3IM +EIaSOdpGRFNUv1uxn9ZzLAWAglJ2DbhKMqJxQvP0KevICmkZzVbn7NLF/3tm7tCd +9Ty8U1baCPu9YjHfMDJQ/l447dguBX0dWYkggyGA/xszyvKSdhjqRRjj0m/zL1Kc +0UYKPWdmGGgJGXp3zP5zrbIQymBdCO0m8k8FZCY42QKBgQD0aAvM5VYDSqG7y6De +P6mwnLOvu/4zugL3Xj2I7KiQ9fRNxGK9yZJuN8xVJUn4JI/Yb22wqTchz2QldLZn +pwqchVOLEMC4476yfplOF9jpMaJ5B5YzVeSd8JmqzKxiPPca8/cqmwCnhdMTuuhe +rD/vq5m3QJQrtrPwmbJpkPfQCQKBgQDmIo6xydrEFwjC8giSrvAzzcS7+TuGDxxy +fCDvwvQRWFJ4sxUAk/HIC7Ru7+y49mmWn9JihTcoXJ9K63M96kRmgSH1mFqX3oXO +rPhdaSs+5RbRzxVS1LufcUreQ/PkQ8E1XZ4+IMhvUPPIIy+Q6z0JSG0rzxkHwZrH +scHx9QlyKwKBgAgssrhXs1jI1kABv4+vcW2G0DvAffAc+JxteoFp6PlwQIuZVfQj +GdK16w+t/5tdHbKPh4uxHTb0SNU34iH1ZFV34CN2m/uTIn213krOYWLrVhPWPR/a +b5sYwQsFbX6bq2grYMj8BjmUMEsopT0FZd4OT0Xh0xLjOYGCooUFZESBAoGAdKyM +f6KA4O10qbEfm45JhVoLJenDvcGngHtiD2xdZPXMShkwyqw25l7YyW2mLFfxTEYW +2ZAEWNgsgkVAleFRcE6Dswnc1CYi3JdhC6qoHCj1GsXMX8QuozVC6dC1s8KOD8o4 +0fK464a1MYB1OxmoRi6L+qvyWrZTDG8YjtzWPyUCgYAqtgHYqhy/AOs6LEtw6N3y +5WNXRZQR4I67skMvoH/tnHcdNJYVwdbnGw53cXQeHRXAOyJ4jV+HS5Uaxhgg8Kq0 +FC3xP6WR0K7eipwV4cPUgEXJjwaDkJyMjMD0Ap1Uo98Msl7VVh8hruyhNuNkCLI2 +WWaeHbjRsUb/Vei41whlIg== +-----END PRIVATE KEY----- diff --git a/packages/core/test/mcp-tls.test.ts b/packages/core/test/mcp-tls.test.ts new file mode 100644 index 000000000000..652492b44f36 --- /dev/null +++ b/packages/core/test/mcp-tls.test.ts @@ -0,0 +1,389 @@ +import { createHash } from "node:crypto" +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir as osTmpdir } from "node:os" +import path from "node:path" +import { Buffer } from "node:buffer" +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit } from "effect" +import { ConfigMCP } from "@opencode/schema/config/mcp" +import { McpClient } from "@opencode/core/mcp/client" +import { McpTls } from "@opencode/core/mcp/tls" +import { hostEnvironmentLayer } from "./fixture/environment" +import { CERT, startTlsMcpServer, TLS_FIXTURE_DIR } from "./fixture/tls-server" + +const SAMPLE_CERT = `-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJALRNRw0Gx+9FMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA0Z3VS5JJcV8xQKNl8N3t9X5N0JF7J8L8HKt1qE4fG2mO5PqR7sTuVwXy +Z1aBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTjKvWxVyZaBcDeF +gHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmN +oPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqN +vWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaB +cDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjI +QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBoJmO5PqR7sTuVwXyZ1aBcDeFgHjIkL +mNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsT +qNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZ +aBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNXv +-----END CERTIFICATE----- +` + +// readCaFile trims trailing whitespace, so match against the trimmed cert. +const SAMPLE_CERT_TRIMMED = SAMPLE_CERT.trim() + +function tmpdir(): { path: string; [Symbol.dispose](): void } { + const dir = realpathSync(mkdtempSync(path.join(osTmpdir(), "opencode-test-tls-"))) + return { + path: dir, + [Symbol.dispose]() { + rmSync(dir, { recursive: true, force: true }) + }, + } +} + +function writeTempFile(dir: string, name: string, content: string) { + const filePath = path.join(dir, name) + writeFileSync(filePath, content) + return filePath +} + +describe("validatePemCert", () => { + test("accepts a valid PEM certificate", () => { + expect(() => McpTls.validatePemCert(SAMPLE_CERT, "test")).not.toThrow() + }) + + test("accepts a CA bundle with multiple certificates", () => { + expect(() => McpTls.validatePemCert(`${SAMPLE_CERT}\n${SAMPLE_CERT}`, "test")).not.toThrow() + }) + + test("rejects content without BEGIN marker", () => { + expect(() => McpTls.validatePemCert("just some text", "test")).toThrow("missing PEM certificate header") + }) + + test("rejects content with BEGIN but no END marker", () => { + expect(() => McpTls.validatePemCert("-----BEGIN CERTIFICATE-----\nbase64data", "test")).toThrow( + "missing PEM certificate footer", + ) + }) + + test("rejects content with empty body between markers", () => { + expect(() => McpTls.validatePemCert("-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----", "test")).toThrow( + "empty PEM certificate body", + ) + }) + + test("rejects partial markers", () => { + expect(() => McpTls.validatePemCert("-----BEGIN CERT-----not real-----END CERT-----", "test")).toThrow( + "missing PEM certificate header", + ) + }) + + test("includes the source name in error messages", () => { + expect(() => McpTls.validatePemCert("bad content", "ca_pem config entry")).toThrow("ca_pem config entry") + }) +}) + +describe("validateFingerprint", () => { + test("accepts bare hex fingerprint", () => { + expect(McpTls.validateFingerprint("a".repeat(64))).toBe("a".repeat(64)) + }) + + test("accepts colon-separated fingerprint", () => { + const result = McpTls.validateFingerprint( + "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + ) + expect(result).toHaveLength(64) + expect(result).not.toContain(":") + }) + + test("accepts SHA256: prefixed fingerprint with or without colons", () => { + expect(McpTls.validateFingerprint("SHA256:" + "a".repeat(64))).toBe("a".repeat(64)) + expect( + McpTls.validateFingerprint( + "SHA256:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + ), + ).toHaveLength(64) + }) + + test("lowercases and trims", () => { + const result = McpTls.validateFingerprint( + " AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99 ", + ) + expect(result).toBe(result.toLowerCase()) + expect(result).toHaveLength(64) + }) + + test("rejects invalid values", () => { + expect(() => McpTls.validateFingerprint("")).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("a".repeat(63))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("a".repeat(65))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("g" + "a".repeat(63))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("SHA1:" + "a".repeat(64))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("' OR '1'='1")).toThrow("Invalid SHA256 fingerprint") + }) +}) + +describe("resolveFilePath", () => { + test("expands ~ to home directory", () => { + const result = McpTls.resolveFilePath("~/test/file.pem", "/workspace") + expect(result).not.toContain("~") + expect(result).toContain("test") + }) + + test("returns absolute paths as-is", () => { + expect(McpTls.resolveFilePath("/absolute/path/ca.pem", "/workspace")).toBe("/absolute/path/ca.pem") + }) + + test("resolves relative paths against workspace", () => { + expect(McpTls.resolveFilePath("certs/ca.pem", "/workspace")).toBe(path.resolve("/workspace", "certs/ca.pem")) + }) +}) + +describe("readCaFile", () => { + test("reads a valid PEM file", () => { + using dir = tmpdir() + const content = McpTls.readCaFile(writeTempFile(dir.path, "ca.pem", SAMPLE_CERT), dir.path) + expect(content).toBe(SAMPLE_CERT_TRIMMED) + }) + + test("resolves relative paths against workspace", () => { + using dir = tmpdir() + writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) + expect(McpTls.readCaFile("ca.pem", dir.path)).toBe(SAMPLE_CERT_TRIMMED) + }) + + test("throws if file does not exist", () => { + expect(() => McpTls.readCaFile("nonexistent.pem", "/workspace")).toThrow("CA file not found") + }) + + test("throws if path is a directory", () => { + using dir = tmpdir() + expect(() => McpTls.readCaFile(dir.path, dir.path)).toThrow("CA path is not a file") + }) + + test("throws if file does not contain a PEM certificate", () => { + using dir = tmpdir() + expect(() => McpTls.readCaFile(writeTempFile(dir.path, "bad.pem", "not a certificate"), dir.path)).toThrow( + "missing PEM certificate header", + ) + }) + + test("includes the original path in error messages", () => { + expect(() => McpTls.readCaFile("nonexistent.pem", "/workspace")).toThrow("nonexistent.pem") + }) +}) + +describe("derToPem", () => { + test("converts DER bytes to PEM format", () => { + const pem = McpTls.derToPem(new TextEncoder().encode("dummy-cert-bytes").buffer) + expect(pem).toStartWith("-----BEGIN CERTIFICATE-----") + expect(pem).toEndWith("-----END CERTIFICATE-----\n") + }) + + test("wraps base64 at 64 characters", () => { + const pem = McpTls.derToPem(Uint8Array.from({ length: 256 }, (_, i) => i).buffer) + for (const line of pem.split("\n").slice(1, -2)) expect(line).toMatch(/^[A-Za-z0-9+/=]{1,64}$/) + }) + + test("empty DER produces minimal PEM", () => { + expect(McpTls.derToPem(new ArrayBuffer(0))).toBe("-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----\n") + }) +}) + +describe("buildTlsCa", () => { + const url = new URL("https://example.com/mcp") + + test("returns empty string when no TLS options configured", async () => { + await expect(McpTls.buildTlsCa({}, "/workspace", url)).resolves.toBe("") + }) + + test("builds CA from ca_pem", async () => { + await expect(McpTls.buildTlsCa({ ca_pem: SAMPLE_CERT }, "/workspace", url)).resolves.toBe(SAMPLE_CERT) + }) + + test("builds CA from ca_file", async () => { + using dir = tmpdir() + await expect(McpTls.buildTlsCa({ ca_file: writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) }, dir.path, url)).resolves.toBe( + SAMPLE_CERT_TRIMMED, + ) + }) + + test("concatenates ca_pem and ca_file", async () => { + using dir = tmpdir() + await expect( + McpTls.buildTlsCa({ ca_pem: SAMPLE_CERT, ca_file: writeTempFile(dir.path, "ca2.pem", SAMPLE_CERT) }, dir.path, url), + ).resolves.toBe(SAMPLE_CERT + SAMPLE_CERT_TRIMMED) + }) + + test("throws if ca_pem is not valid PEM", async () => { + await expect(McpTls.buildTlsCa({ ca_pem: "not a cert" }, "/workspace", url)).rejects.toThrow( + "missing PEM certificate header", + ) + }) + + test("throws if ca_file does not exist", async () => { + await expect(McpTls.buildTlsCa({ ca_file: "nonexistent.pem" }, "/workspace", url)).rejects.toThrow("CA file not found") + }) + + test("throws if fingerprint is invalid format", async () => { + await expect(McpTls.buildTlsCa({ fingerprint: "not-a-fingerprint" }, "/workspace", url)).rejects.toThrow( + "Invalid SHA256 fingerprint", + ) + }) +}) + +describe("withTlsCa", () => { + test("injects tls.ca and preserves the caller's init", async () => { + const calls: Array<{ url: string | URL; init?: RequestInit }> = [] + const base = ((url: string | URL, init?: RequestInit) => { + calls.push({ url, init }) + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + await McpTls.withTlsCa(SAMPLE_CERT, base)("https://example.com/mcp", { method: "POST", headers: { a: "b" } }) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe("https://example.com/mcp") + const init = calls[0].init as BunFetchRequestInit + expect(init.method).toBe("POST") + expect(init.headers).toEqual({ a: "b" }) + expect(init.tls).toEqual({ ca: SAMPLE_CERT }) + }) + + test("works when the caller passes no init", async () => { + const calls: Array<{ init?: RequestInit }> = [] + const base = ((_: string | URL, init?: RequestInit) => { + calls.push({ init }) + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + await McpTls.withTlsCa(SAMPLE_CERT, base)("https://example.com/mcp") + + expect((calls[0].init as BunFetchRequestInit).tls).toEqual({ ca: SAMPLE_CERT }) + }) +}) + +// The fingerprint is the SHA256 of the DER body, the same value +// tls.getPeerX509Certificate().fingerprint256 reports. +const FINGERPRINT = createHash("sha256") + .update(Buffer.from(CERT.split("\n").slice(1, -1).join(""), "base64")) + .digest("hex") + +const connect = (name: string, config: ConfigMCP.Remote) => + McpClient.connect(name, config, import.meta.dir).pipe(Effect.provide(hostEnvironmentLayer)) + +// The connection lives only as long as use; closing the scope tears the transport down. +const withConnection = ( + name: string, + config: ConfigMCP.Remote, + use: (connection: McpClient.Connection) => Effect.Effect, +) => Effect.scoped(connect(name, config).pipe(Effect.flatMap(use))) + +describe("MCP connect over TLS", () => { + test("trusts a self-signed server via ca_file", async () => { + const server = await startTlsMcpServer() + try { + await Effect.runPromise( + withConnection( + "tls-ca", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { ca_file: path.join(TLS_FIXTURE_DIR, "localhost.crt") } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + + test("trusts a self-signed server via ca_pem", async () => { + const server = await startTlsMcpServer() + try { + await Effect.runPromise( + withConnection( + "tls-pem", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { ca_pem: CERT } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + + test("pins the certificate by fingerprint against an IP host", async () => { + const server = await startTlsMcpServer() + try { + await Effect.runPromise( + withConnection( + "tls-pin", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { fingerprint: FINGERPRINT } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + + test("buildTlsCa pins by fingerprint and the result acts as a trust anchor", async () => { + const server = await startTlsMcpServer() + try { + const ca = await McpTls.buildTlsCa({ fingerprint: FINGERPRINT }, import.meta.dir, new URL(server.url)) + expect(ca).toStartWith("-----BEGIN CERTIFICATE-----") + // A bare GET is rejected by the streamable transport, so prove the trust anchor with an initialize. + const response = await McpTls.withTlsCa(ca, globalThis.fetch)(server.url, { + method: "POST", + headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "opencode", version: "1.0.0" } }, + }), + }) + expect(response.status).toBe(200) + await response.body?.cancel() + } finally { + await server.close() + } + }) + + test("rejects a fingerprint mismatch before connecting", async () => { + const server = await startTlsMcpServer() + try { + const exit = await Effect.runPromise( + Effect.scoped( + connect("tls-bad-pin", new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { fingerprint: "a".repeat(64) } })), + ).pipe(Effect.exit), + ) + if (Exit.isSuccess(exit)) throw new Error("connect unexpectedly succeeded") + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(McpClient.ConnectError) + if (!(error instanceof McpClient.ConnectError)) throw new Error("expected ConnectError") + expect(error.message).toContain("fingerprint mismatch") + } finally { + await server.close() + } + }) + + test("rejects a certificate no configured CA signed", async () => { + const server = await startTlsMcpServer() + try { + const exit = await Effect.runPromise( + Effect.scoped( + connect("tls-wrong-ca", new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { ca_pem: SAMPLE_CERT } })), + ).pipe(Effect.exit), + ) + if (Exit.isSuccess(exit)) throw new Error("connect unexpectedly succeeded") + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(McpClient.ConnectError) + } finally { + await server.close() + } + }) +}) diff --git a/packages/schema/src/config/mcp.ts b/packages/schema/src/config/mcp.ts index 1a684794304f..486be74af43a 100644 --- a/packages/schema/src/config/mcp.ts +++ b/packages/schema/src/config/mcp.ts @@ -12,6 +12,8 @@ export const OAuth = Mcp.OAuthConfig export type OAuth = Mcp.OAuthConfig export const Remote = Mcp.RemoteConfig export type Remote = Mcp.RemoteConfig +export const Tls = Mcp.TlsConfig +export type Tls = Mcp.TlsConfig export const Server = Mcp.ServerConfig export const Protocol = Mcp.Protocol export type Protocol = Mcp.Protocol diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 2d782dd6a40a..fa979c16cc1b 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -50,6 +50,20 @@ export class OAuthConfig extends Schema.Class("Mcp.OAuthConfig")({ }), }) {} +export class TlsConfig extends Schema.Class("Mcp.TlsConfig")({ + ca_file: Schema.String.pipe(optional).annotate({ + description: + "Path to a PEM-encoded CA certificate file to trust for this server. Relative paths resolve from the workspace; ~ expands to the home directory.", + }), + ca_pem: Schema.String.pipe(optional).annotate({ + description: "Inline PEM-encoded CA certificate content to trust for this server.", + }), + fingerprint: Schema.String.pipe(optional).annotate({ + description: + "SHA256 fingerprint of the server certificate to pin. Format: 'SHA256:XX:XX:...', 'XX:XX:...', or bare hex.", + }), +}) {} + export class RemoteConfig extends Schema.Class("Mcp.RemoteConfig")({ type: Schema.Literal("remote"), url: Schema.String, @@ -61,6 +75,10 @@ export class RemoteConfig extends Schema.Class("Mcp.RemoteConfig") }), timeout: TimeoutConfig.pipe(optional), protocol: Protocol.pipe(optional), + tls: TlsConfig.pipe(optional).annotate({ + description: + "TLS trust for this server: a custom CA and/or a pinned certificate fingerprint. Applies to this server only.", + }), }) {} export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/schema/test/mcp.test.ts b/packages/schema/test/mcp.test.ts index 75daa7fe1c02..466d91a0ac05 100644 --- a/packages/schema/test/mcp.test.ts +++ b/packages/schema/test/mcp.test.ts @@ -15,6 +15,30 @@ describe("Mcp resources", () => { }) }) + test("decodes remote TLS trust config and omits unset fields on encode", () => { + const decoded = Schema.decodeUnknownSync(Mcp.RemoteConfig)({ + type: "remote", + url: "https://192.168.1.1/mcp", + tls: { ca_file: "~/.config/mcp/ca.pem", fingerprint: "SHA256:AA:BB" }, + }) + expect(Schema.encodeSync(Mcp.RemoteConfig)(decoded)).toEqual({ + type: "remote", + url: "https://192.168.1.1/mcp", + tls: { ca_file: "~/.config/mcp/ca.pem", fingerprint: "SHA256:AA:BB" }, + }) + const bare = Schema.decodeUnknownSync(Mcp.RemoteConfig)({ type: "remote", url: "https://example.com/mcp" }) + expect(Schema.encodeSync(Mcp.RemoteConfig)(bare)).toEqual({ type: "remote", url: "https://example.com/mcp" }) + }) + + test("rejects non-string TLS trust fields", () => { + expect(() => + Schema.decodeUnknownSync(Mcp.RemoteConfig)({ type: "remote", url: "https://example.com/mcp", tls: { ca_pem: 1 } }), + ).toThrow() + expect(() => + Schema.decodeUnknownSync(Mcp.RemoteConfig)({ type: "remote", url: "https://example.com/mcp", tls: "pin" }), + ).toThrow() + }) + test("preserves text and base64 blob contents", () => { expect( Schema.decodeUnknownSync(Mcp.ResourceContent)({ diff --git a/services/www/src/docs/content/mcp-servers.mdx b/services/www/src/docs/content/mcp-servers.mdx index bfea1f696323..490cd574258b 100644 --- a/services/www/src/docs/content/mcp-servers.mdx +++ b/services/www/src/docs/content/mcp-servers.mdx @@ -174,6 +174,7 @@ Use configuration for other options. Store secrets in environment variables rath | `codemode` | No | Set to `false` to expose tools directly instead of through Code Mode. Defaults to `true`. | | `timeout` | No | Per-server timeout overrides. | | `protocol` | No | `legacy` (default), `auto`, or `2026-07-28`. See [Protocol version](#protocol-version). | +| `tls` | No | TLS trust settings for self-signed or private-CA certificates. See [TLS Trust](#tls-trust). | Use `oauth: false` only when the server exclusively uses an API key or another header credential: @@ -186,6 +187,62 @@ Use `oauth: false` only when the server exclusively uses an API key or another h } ``` +## TLS Trust + +Servers on private networks often use self-signed certificates or certificates from a private certificate authority. Trust one for a single server without changing verification for the rest of OpenCode: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "servers": { + "opnsense": { + "type": "remote", + "url": "https://192.168.1.1/mcp", + "tls": { + "ca_file": "~/.config/mcp/opnsense-ca.pem", + }, + }, + }, + }, +} +``` + +| Field | Description | +| --- | --- | +| `ca_file` | PEM-encoded CA certificate file. Relative paths resolve from the workspace; `~` expands to the home directory. | +| `ca_pem` | Inline PEM-encoded CA certificate. Useful when the appliance ships its configuration self-contained. | +| `fingerprint` | SHA256 fingerprint of the server certificate to pin, like an SSH host key. | + +Set `ca_file` and `ca_pem` together to trust several authorities at once: + +```jsonc +{ + "tls": { + "ca_file": "/etc/company/ca.pem", + "ca_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + }, +} +``` + +Pin the exact certificate a server presents with `fingerprint`, formatted as `SHA256:AA:BB:CC:...`, `AA:BB:CC:...`, or bare hex: + +```jsonc +{ + "tls": { + "fingerprint": "SHA256:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + }, +} +``` + +OpenCode reads the server's certificate once, compares the fingerprint, and then trusts exactly that certificate for the connection. Get a fingerprint from the server with: + +```sh +openssl s_client -connect 192.168.1.1:443 /dev/null | openssl x509 -noout -fingerprint -sha256 +``` + +A TLS failure shows up as the server's connection status in `opencode mcp list` and `/mcps`. Unlike [NODE_EXTRA_CA_CERTS](/network), the trust applies to this server only. + ## OAuth OAuth is enabled for remote servers unless `oauth` is `false`. OpenCode discovers the authorization server, signs in with PKCE, and refreshes tokens. Credentials are stored outside project configuration. From 8972042a4d9f18ef485b7ea666603b5f82e8f3c6 Mon Sep 17 00:00:00 2001 From: karup <8500529+karup@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:55:11 -0700 Subject: [PATCH 2/4] fix(core): skip name matching for fingerprint-pinned TLS certificates --- packages/core/src/mcp/client.ts | 8 +- packages/core/src/mcp/tls.ts | 105 +++++++++++---- packages/core/test/fixture/tls-server.ts | 4 + packages/core/test/fixture/tls/appliance.crt | 19 +++ packages/core/test/fixture/tls/appliance.key | 28 ++++ packages/core/test/mcp-tls.test.ts | 134 +++++++++++++++---- 6 files changed, 241 insertions(+), 57 deletions(-) create mode 100644 packages/core/test/fixture/tls/appliance.crt create mode 100644 packages/core/test/fixture/tls/appliance.key diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 85931abddeb9..2144e8833dcc 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -322,14 +322,14 @@ export const connect = Effect.fnUntraced(function* ( if (addedCodemode) url.searchParams.set("codemode", "false") // A missing CA file or a fingerprint mismatch fails the connect instead of the first request. const tls = config.tls - const ca = tls - ? yield* Effect.tryPromise({ try: () => McpTls.buildTlsCa(tls, directory, url), catch: toError }) - : "" + const resolved = tls + ? yield* Effect.tryPromise({ try: () => McpTls.resolveTls(tls, directory, url), catch: toError }) + : undefined const open = Effect.fnUntraced(function* (url: URL) { session.transport = new StreamableHTTPClientTransport(url, { requestInit: config.headers ? { headers: config.headers } : undefined, authProvider, - fetch: ca ? McpTls.withTlsCa(ca, fetch) : fetch, + fetch: resolved ? McpTls.withTls(resolved, fetch) : fetch, }) return yield* initialize(session.transport) }) diff --git a/packages/core/src/mcp/tls.ts b/packages/core/src/mcp/tls.ts index 652b025ac956..b079765ea877 100644 --- a/packages/core/src/mcp/tls.ts +++ b/packages/core/src/mcp/tls.ts @@ -1,10 +1,11 @@ export * as McpTls from "./tls.js" +import { createHash } from "node:crypto" import { closeSync, fstatSync, openSync, readFileSync } from "node:fs" import { isIP } from "node:net" import { homedir } from "node:os" import path from "node:path" -import tls from "node:tls" +import tls, { type PeerCertificate } from "node:tls" import type { ConfigMCP } from "@opencode/schema/config/mcp" const PEM_CERT_HEADER = "-----BEGIN CERTIFICATE-----" @@ -75,51 +76,97 @@ export function readCaFile(filePath: string, workspaceDir: string): string { } /** - * Build the PEM CA bundle for a server's TLS config. An empty result means "use the system - * trust store". A pinned fingerprint opens one pre-flight TLS connection; its certificate is - * verified and returned as the trust anchor for the real connection. + * The trust rules a server's connections are subject to. `ca` is a PEM trust anchor bundle; + * `fingerprint` pins one exact certificate, making its SHA256 fingerprint the identity check. */ -export async function buildTlsCa(config: ConfigMCP.Tls, workspaceDir: string, url: URL): Promise { +export type ResolvedTls = + | { readonly ca: string } + | { readonly fingerprint: string } + +/** + * Resolve a server's TLS config into the trust rules for its connections. An undefined result + * means "use the system trust store". A pinned fingerprint opens one pre-flight TLS connection; + * the presented certificate must match it exactly. Name matching is deliberately not enforced: + * LAN appliance certificates often omit the IP SANs a hostname check would require, and the + * fingerprint already identifies the certificate the user pinned. + */ +export async function resolveTls( + config: ConfigMCP.Tls, + workspaceDir: string, + url: URL, +): Promise { + if (config.fingerprint !== undefined) { + const expected = validateFingerprint(config.fingerprint) + const cert = await probeCertificate(url.hostname, port(url)) + if (cert.fingerprint !== expected) + throw new Error(`Certificate fingerprint mismatch.\nExpected: ${expected}\nGot: ${cert.fingerprint}`) + return { fingerprint: expected } + } if (config.ca_pem !== undefined) validatePemCert(config.ca_pem, "ca_pem config entry") - const pinned = config.fingerprint !== undefined ? await pinFingerprint(config.fingerprint, url) : undefined - return [ + const ca = [ config.ca_pem, config.ca_file !== undefined ? readCaFile(config.ca_file, workspaceDir) : undefined, - pinned, ] .filter((part): part is string => part !== undefined) .join("") + return ca === "" ? undefined : { ca } } /** - * Wrap a fetch so its requests trust the given CA. The trust is scoped to the returned fetch - * and does not affect other connections, including redirects it follows. + * Wrap a fetch so its requests apply the resolved TLS trust rules. The trust is scoped to the + * returned fetch and does not affect other connections, including redirects it follows. A pinned + * fingerprint disables certificate and name validation on the real connection: the pre-flight + * comparison is the effective check on current runtimes, and the identity hook is the check on + * runtimes that honor it. */ -export function withTlsCa( - ca: string, +export function withTls( + resolved: ResolvedTls, base: (url: string | URL, init?: RequestInit) => Promise, ): (url: string | URL, init?: RequestInit) => Promise { - return (url, init) => base(url, { ...init, tls: { ca } } as BunFetchRequestInit) -} - -function pinFingerprint(raw: string, url: URL): Promise { - const expected = validateFingerprint(raw) - return verifyServerFingerprint(url.hostname, port(url), expected) + if ("fingerprint" in resolved) { + const fingerprint = resolved.fingerprint + return (url, init) => + base(url, { + ...init, + tls: { + rejectUnauthorized: false, + // Current runtimes ignore this callback's result when rejectUnauthorized is false, so + // the pre-flight comparison is the effective check; the callback is the check on + // runtimes that enforce it. + checkServerIdentity: (_hostname: string, cert: PeerCertificate) => { + const presented = createHash("sha256").update(cert.raw).digest("hex") + if (presented !== fingerprint) + return new Error(`Certificate fingerprint mismatch.\nExpected: ${fingerprint}\nGot: ${presented}`) + }, + }, + } as BunFetchRequestInit) + } + return (url, init) => base(url, { ...init, tls: { ca: resolved.ca } } as BunFetchRequestInit) } /** - * Open a raw TLS socket, compare the presented certificate's SHA256 fingerprint, and return the - * certificate as PEM. This is the only place verification is relaxed (rejectUnauthorized: false), - * and only to read the certificate; the socket is destroyed before any application data is sent. + * True when a connection failure is a TLS certificate verification error rather than a network, + * protocol, or HTTP failure. Current runtimes report these as message-only errors ("self signed + * certificate", "ERR_TLS_CERT_ALTNAME_INVALID", "certificate has expired"); Node-style codes are + * matched as well for the runtimes that expose them. */ -function verifyServerFingerprint(hostname: string, portNumber: number, expectedFingerprint: string): Promise { - return probeCertificate(hostname, portNumber).then((cert) => { - if (cert.fingerprint !== expectedFingerprint) - throw new Error( - `Certificate fingerprint mismatch.\nExpected: ${expectedFingerprint}\nGot: ${cert.fingerprint}`, - ) - return cert.pem - }) +export function isTlsCertificateError(error: unknown): boolean { + const patterns = [ + /self[- ]signed/i, + /ERR_TLS_CERT_ALTNAME_INVALID|CERT_ALTNAME/i, + /UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|unable to verify/i, + /CERT_HAS_EXPIRED|certificate has expired/i, + /DEPTH_ZERO/i, + ] + let current: unknown = error + for (let depth = 0; depth < 5; depth++) { + if (!(current instanceof Error)) break + const code = (current as { code?: unknown }).code + const text = `${code === undefined ? "" : `${code} `}${current.message}` + if (patterns.some((pattern) => pattern.test(text))) return true + current = (current as { cause?: unknown }).cause + } + return false } export interface Certificate { diff --git a/packages/core/test/fixture/tls-server.ts b/packages/core/test/fixture/tls-server.ts index ce8bd4e370ab..b84ca22e7d68 100644 --- a/packages/core/test/fixture/tls-server.ts +++ b/packages/core/test/fixture/tls-server.ts @@ -7,6 +7,10 @@ export const TLS_FIXTURE_DIR = path.join(import.meta.dir, "tls") // Long-lived self-signed certificate for 127.0.0.1 and localhost. export const CERT = readFileSync(path.join(TLS_FIXTURE_DIR, "localhost.crt"), "utf-8") export const KEY = readFileSync(path.join(TLS_FIXTURE_DIR, "localhost.key"), "utf-8") +// Appliance-style certificate: a CN and no subjectAltName. A hostname or IP name check fails +// against it, which is exactly why the fingerprint is the identity check when it is pinned. +export const CERT_APPLIANCE = readFileSync(path.join(TLS_FIXTURE_DIR, "appliance.crt"), "utf-8") +export const KEY_APPLIANCE = readFileSync(path.join(TLS_FIXTURE_DIR, "appliance.key"), "utf-8") export function startTlsMcpServer(options: { cert?: string; key?: string; port?: number } = {}) { const cert = options.cert ?? CERT diff --git a/packages/core/test/fixture/tls/appliance.crt b/packages/core/test/fixture/tls/appliance.crt new file mode 100644 index 000000000000..02cf5f321735 --- /dev/null +++ b/packages/core/test/fixture/tls/appliance.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIUHT9Q3Me6KRf/symqQZXz7N440RowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPYXBwbGlhbmNlLmxvY2FsMB4XDTI2MTAyOTIwMDAyMFoX +DTM2MTAyNjIwMDAyMFowGjEYMBYGA1UEAwwPYXBwbGlhbmNlLmxvY2FsMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAys+pDvBb235QEzENdwA0lWeeGVL7 +oJOv3A9lC1m172GZp0xHdvlyke/CwptopRQjr5+3aUq7sIDw4phSSRUfCtTFkbC2 +/AQ8ztnXhVcERuHz+uNZfUUnEpQUQiNmUDxACkuijAvCBlub/wj/VN2LJr2OeW3V +3D+KfW+f4ZtHCXPuDRGVBLqd21PgQJTRUTjXp+tUEp/B3Wr4bd2TPqzpIxJGM9/l +UOybgDFbz6k+3taOPQn9y5IcXU+8GD/AFbt6kxX7AXGvDGPe9hxop5uZxEKJhfQ5 +XPPLlx1oXxbyGZxelwwUkjLgiXJW3ekFjYIOaIcEbe+nlkf+9+JnTxG3mwIDAQAB +o1MwUTAdBgNVHQ4EFgQUZE/eBykoTexgw0u6lIj4mka7+OowHwYDVR0jBBgwFoAU +ZE/eBykoTexgw0u6lIj4mka7+OowDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B +AQsFAAOCAQEAFcV0CZbU7nt3tJ/sSn4p4X71v5gKBPUASk/Y/RFhZ2xnwAk4Cgs2 +RCOlzCObF4+ZHYYVBaavTOtb4DWzd5jBeFQdzbYM+YWoQoujlDDxG+aerNMiMhnV +oD9J1vQSgRhHcYI2KJdxAi1cQK0JsA+xZ82Z6vup5KGLgUoZWd1pZKUdG1Mnlie7 +1LPDCtVMDZvGFiJJdqI+GFiYahQ1w5R1uuuPuFujHiNK5QLQINq+K9eyDq2HSacU +mKFSKrTfa5z+rYye7E37JBJ62l1G7q0kGS90e4tQJCckAl7Om5fLdZnqlw/TcjNA +sA1yTeWDYZ7UCwD+NzZml13aRc5H70brxw== +-----END CERTIFICATE----- diff --git a/packages/core/test/fixture/tls/appliance.key b/packages/core/test/fixture/tls/appliance.key new file mode 100644 index 000000000000..55ee1d2dbfce --- /dev/null +++ b/packages/core/test/fixture/tls/appliance.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDKz6kO8FvbflAT +MQ13ADSVZ54ZUvugk6/cD2ULWbXvYZmnTEd2+XKR78LCm2ilFCOvn7dpSruwgPDi +mFJJFR8K1MWRsLb8BDzO2deFVwRG4fP641l9RScSlBRCI2ZQPEAKS6KMC8IGW5v/ +CP9U3YsmvY55bdXcP4p9b5/hm0cJc+4NEZUEup3bU+BAlNFRONen61QSn8Hdavht +3ZM+rOkjEkYz3+VQ7JuAMVvPqT7e1o49Cf3LkhxdT7wYP8AVu3qTFfsBca8MY972 +HGinm5nEQomF9Dlc88uXHWhfFvIZnF6XDBSSMuCJclbd6QWNgg5ohwRt76eWR/73 +4mdPEbebAgMBAAECggEAGUzJcq8iTmHV/Gbonpk8dgzciJ00/aWe0Q1slftlekzL +HbcizSE2HSVzPtKjPcCi6QQ8HZrwigazxhipXyuO2UuMr+2DDjI7IbTEMYKW6n7C +cGBPKBTp2GP3mP6Qg17YwFAKIX1QAXoJHkoRjBUtCDKqZNSCUrzselRj9gFoXbHK +5jtjTVEZSs3smTM3crlRizdKNvzP+cqx4pac8kPjuDH9yT8oicg+Bnmt4/GAZsWp +brSYai7HScudx0A+IDLuKfP9tYVeIoe4ebgpyxamWDjrwVRpn5tDgE84A2knwVlN +kMSQ8ySleziCRZm1Z3GRdMv0W9zhI/6TmPXsctjtgQKBgQD+Mz5P72usOFdvEUem +sCid8SeKxeu/YM0ODbEOFqQRNf732ZJ05Q6NeJHmbA19DULCSYYgNiA+yxfA1Qet +BlbN7JBuuVRbUGOjkrYCOHHSwWdOoQLRXZ0VvwRyt/nRVxkhF9u+B7ESg5vgmkj0 +m/g0qWVl5uDhH+R4wRFkTp4sgQKBgQDMP0VHmPefvYhmIJsRnKPPq8934H//9ntj +KC+qgEOmXthIESgq7IIkLU1AEyMg3GTeHfGFKaC590TZTsXdKBDyk4KBampmOlqJ +6F3Z7LL2SEDjfd7q1qaiMWE8VoY5n0yeBGP3a8kx1l4QPiVlqAAnjFQjVP8OVHRK +H4jWg1gGGwKBgG9L1F5/Py9aQ73XXP24ZzAv49Y4re1O3rjmebznYNJAR/AQjLUt +jN+YCJlQed9krPQP6O+1gwy7Mx00qHWzLtJeXyZ7clnAqr91fsceG4fhMfAVQvyT +9jENP09TCcgEUHZS0d0z1O18PgVqx8KBf3zfJgwluBQZOJk+PvUiDtMBAoGAG9PO ++5dCvZfUGp38oqSlyOiOprWGmvgZDYRvIhrBnMw1SXRU9awtewJVERLKaXK9p2mK ++2qVhGDFF5pc6OnJjVM9p6Y5At6WcU2gY82VAGsNtDD1CyfdoiF6Lv9W4Ejs5maQ +vCgKTGj5fdsJu1TtWHYNDJdK/MfS5DpxqVBR0NsCgYBqLps6f/ZnHkam5xhcXwuF +eQILtW470j00ZiY8eW3vsO3jDaJY4kKDqCg/vJvCvrJeI0Arz5xfK3U+0nT4/3OM +BxKm9KiZFy1FNMc4pQeJN4u1M60McZREw//ncU+B/lOTQ2RhOEDB79+nKhuezjkC +vI9mk1tEhXtadS6g1C83zQ== +-----END PRIVATE KEY----- diff --git a/packages/core/test/mcp-tls.test.ts b/packages/core/test/mcp-tls.test.ts index 652492b44f36..292a8f796551 100644 --- a/packages/core/test/mcp-tls.test.ts +++ b/packages/core/test/mcp-tls.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto" import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir as osTmpdir } from "node:os" import path from "node:path" +import type { PeerCertificate } from "node:tls" import { Buffer } from "node:buffer" import { describe, expect, test } from "bun:test" import { Cause, Effect, Exit } from "effect" @@ -9,7 +10,7 @@ import { ConfigMCP } from "@opencode/schema/config/mcp" import { McpClient } from "@opencode/core/mcp/client" import { McpTls } from "@opencode/core/mcp/tls" import { hostEnvironmentLayer } from "./fixture/environment" -import { CERT, startTlsMcpServer, TLS_FIXTURE_DIR } from "./fixture/tls-server" +import { CERT, CERT_APPLIANCE, KEY_APPLIANCE, startTlsMcpServer, TLS_FIXTURE_DIR } from "./fixture/tls-server" const SAMPLE_CERT = `-----BEGIN CERTIFICATE----- MIIDXTCCAkWgAwIBAgIJALRNRw0Gx+9FMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV @@ -192,49 +193,71 @@ describe("derToPem", () => { }) }) -describe("buildTlsCa", () => { +describe("resolveTls", () => { const url = new URL("https://example.com/mcp") - test("returns empty string when no TLS options configured", async () => { - await expect(McpTls.buildTlsCa({}, "/workspace", url)).resolves.toBe("") + test("returns undefined when no TLS options configured", async () => { + await expect(McpTls.resolveTls({}, "/workspace", url)).resolves.toBeUndefined() }) - test("builds CA from ca_pem", async () => { - await expect(McpTls.buildTlsCa({ ca_pem: SAMPLE_CERT }, "/workspace", url)).resolves.toBe(SAMPLE_CERT) + test("resolves CA from ca_pem", async () => { + await expect(McpTls.resolveTls({ ca_pem: SAMPLE_CERT }, "/workspace", url)).resolves.toEqual({ ca: SAMPLE_CERT }) }) - test("builds CA from ca_file", async () => { + test("resolves CA from ca_file", async () => { using dir = tmpdir() - await expect(McpTls.buildTlsCa({ ca_file: writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) }, dir.path, url)).resolves.toBe( - SAMPLE_CERT_TRIMMED, - ) + await expect( + McpTls.resolveTls({ ca_file: writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) }, dir.path, url), + ).resolves.toEqual({ ca: SAMPLE_CERT_TRIMMED }) }) test("concatenates ca_pem and ca_file", async () => { using dir = tmpdir() await expect( - McpTls.buildTlsCa({ ca_pem: SAMPLE_CERT, ca_file: writeTempFile(dir.path, "ca2.pem", SAMPLE_CERT) }, dir.path, url), - ).resolves.toBe(SAMPLE_CERT + SAMPLE_CERT_TRIMMED) + McpTls.resolveTls({ ca_pem: SAMPLE_CERT, ca_file: writeTempFile(dir.path, "ca2.pem", SAMPLE_CERT) }, dir.path, url), + ).resolves.toEqual({ ca: SAMPLE_CERT + SAMPLE_CERT_TRIMMED }) }) test("throws if ca_pem is not valid PEM", async () => { - await expect(McpTls.buildTlsCa({ ca_pem: "not a cert" }, "/workspace", url)).rejects.toThrow( + await expect(McpTls.resolveTls({ ca_pem: "not a cert" }, "/workspace", url)).rejects.toThrow( "missing PEM certificate header", ) }) test("throws if ca_file does not exist", async () => { - await expect(McpTls.buildTlsCa({ ca_file: "nonexistent.pem" }, "/workspace", url)).rejects.toThrow("CA file not found") + await expect(McpTls.resolveTls({ ca_file: "nonexistent.pem" }, "/workspace", url)).rejects.toThrow("CA file not found") }) test("throws if fingerprint is invalid format", async () => { - await expect(McpTls.buildTlsCa({ fingerprint: "not-a-fingerprint" }, "/workspace", url)).rejects.toThrow( + await expect(McpTls.resolveTls({ fingerprint: "not-a-fingerprint" }, "/workspace", url)).rejects.toThrow( "Invalid SHA256 fingerprint", ) }) + + test("accepts a matching fingerprint against the presented certificate", async () => { + const server = await startTlsMcpServer() + try { + await expect( + McpTls.resolveTls({ fingerprint: FINGERPRINT }, import.meta.dir, new URL(server.url)), + ).resolves.toEqual({ fingerprint: FINGERPRINT }) + } finally { + await server.close() + } + }) + + test("throws on a fingerprint mismatch before connecting", async () => { + const server = await startTlsMcpServer() + try { + await expect( + McpTls.resolveTls({ fingerprint: "a".repeat(64) }, import.meta.dir, new URL(server.url)), + ).rejects.toThrow("fingerprint mismatch") + } finally { + await server.close() + } + }) }) -describe("withTlsCa", () => { +describe("withTls", () => { test("injects tls.ca and preserves the caller's init", async () => { const calls: Array<{ url: string | URL; init?: RequestInit }> = [] const base = ((url: string | URL, init?: RequestInit) => { @@ -242,7 +265,7 @@ describe("withTlsCa", () => { return Promise.resolve(new Response("ok")) }) as typeof fetch - await McpTls.withTlsCa(SAMPLE_CERT, base)("https://example.com/mcp", { method: "POST", headers: { a: "b" } }) + await McpTls.withTls({ ca: SAMPLE_CERT }, base)("https://example.com/mcp", { method: "POST", headers: { a: "b" } }) expect(calls).toHaveLength(1) expect(calls[0].url).toBe("https://example.com/mcp") @@ -259,10 +282,53 @@ describe("withTlsCa", () => { return Promise.resolve(new Response("ok")) }) as typeof fetch - await McpTls.withTlsCa(SAMPLE_CERT, base)("https://example.com/mcp") + await McpTls.withTls({ ca: SAMPLE_CERT }, base)("https://example.com/mcp") expect((calls[0].init as BunFetchRequestInit).tls).toEqual({ ca: SAMPLE_CERT }) }) + + test("disables certificate validation and installs the fingerprint check for a pin", async () => { + const calls: Array<{ init?: RequestInit }> = [] + const base = ((_: string | URL, init?: RequestInit) => { + calls.push({ init }) + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + await McpTls.withTls({ fingerprint: FINGERPRINT }, base)("https://127.0.0.1:8443/mcp") + + const tls = (calls[0].init as BunFetchRequestInit).tls + expect(tls?.rejectUnauthorized).toBe(false) + const check = tls?.checkServerIdentity + expect(typeof check).toBe("function") + const der = Buffer.from(CERT.split("\n").slice(1, -1).join(""), "base64") + expect(check!("127.0.0.1", { raw: der } as PeerCertificate)).toBeUndefined() + const mismatch = check!("127.0.0.1", { raw: Buffer.alloc(32) } as PeerCertificate) + expect(mismatch).toBeInstanceOf(Error) + expect((mismatch as Error).message).toContain("fingerprint mismatch") + }) +}) + +describe("isTlsCertificateError", () => { + const tlsErrors: Array<[string, unknown]> = [ + ["self-signed message (bun)", new Error("self signed certificate")], + ["altname message", new Error('ERR_TLS_CERT_ALTNAME_INVALID fetching "https://127.0.0.1:8443/mcp"')], + ["expired message", new Error("certificate has expired")], + ["node depth-zero code", Object.assign(new Error("self-signed certificate"), { code: "DEPTH_ZERO_SELF_SIGNED_CERT" })], + ["node self-signed-in-chain code", Object.assign(new Error("unable to verify the first certificate"), { code: "SELF_SIGNED_CERT_IN_CHAIN" })], + ["caused by a self-signed error", Object.assign(new TypeError("fetch failed"), { cause: new Error("self signed certificate") })], + ["nested cause", Object.assign(new Error("connect failed"), { cause: Object.assign(new Error("tls error"), { cause: new Error("certificate has expired") }) })], + ] + for (const [name, error] of tlsErrors) test(`matches ${name}`, () => expect(McpTls.isTlsCertificateError(error)).toBe(true)) + + const otherErrors: Array<[string, unknown]> = [ + ["connection refused", new Error("Unable to connect. Is the computer able to access the url?")], + ["http failure", new Error("HTTP error: 500 Internal Server Error")], + ["bare fetch failed", new TypeError("fetch failed")], + ["timeout", new Error("Request timed out after 30000ms")], + ["non-error value", "self signed certificate is not the issue"], + ["null", null], + ] + for (const [name, error] of otherErrors) test(`rejects ${name}`, () => expect(McpTls.isTlsCertificateError(error)).toBe(false)) }) // The fingerprint is the SHA256 of the DER body, the same value @@ -270,6 +336,9 @@ describe("withTlsCa", () => { const FINGERPRINT = createHash("sha256") .update(Buffer.from(CERT.split("\n").slice(1, -1).join(""), "base64")) .digest("hex") +const FINGERPRINT_APPLIANCE = createHash("sha256") + .update(Buffer.from(CERT_APPLIANCE.split("\n").slice(1, -1).join(""), "base64")) + .digest("hex") const connect = (name: string, config: ConfigMCP.Remote) => McpClient.connect(name, config, import.meta.dir).pipe(Effect.provide(hostEnvironmentLayer)) @@ -330,13 +399,14 @@ describe("MCP connect over TLS", () => { } }) - test("buildTlsCa pins by fingerprint and the result acts as a trust anchor", async () => { - const server = await startTlsMcpServer() + test("a fingerprint pin reaches an appliance certificate without IP SANs", async () => { + const server = await startTlsMcpServer({ cert: CERT_APPLIANCE, key: KEY_APPLIANCE }) try { - const ca = await McpTls.buildTlsCa({ fingerprint: FINGERPRINT }, import.meta.dir, new URL(server.url)) - expect(ca).toStartWith("-----BEGIN CERTIFICATE-----") - // A bare GET is rejected by the streamable transport, so prove the trust anchor with an initialize. - const response = await McpTls.withTlsCa(ca, globalThis.fetch)(server.url, { + const resolved = await McpTls.resolveTls({ fingerprint: FINGERPRINT_APPLIANCE }, import.meta.dir, new URL(server.url)) + if (!resolved || !("fingerprint" in resolved) || resolved.fingerprint !== FINGERPRINT_APPLIANCE) + throw new Error(`unexpected resolved tls: ${JSON.stringify(resolved)}`) + // A bare GET is rejected by the streamable transport, so prove the trust with an initialize. + const response = await McpTls.withTls(resolved, globalThis.fetch)(server.url, { method: "POST", headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, body: JSON.stringify({ @@ -353,6 +423,22 @@ describe("MCP connect over TLS", () => { } }) + test("connects an appliance certificate without IP SANs via a fingerprint pin", async () => { + const server = await startTlsMcpServer({ cert: CERT_APPLIANCE, key: KEY_APPLIANCE }) + try { + await Effect.runPromise( + withConnection( + "tls-appliance", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { fingerprint: FINGERPRINT_APPLIANCE } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + test("rejects a fingerprint mismatch before connecting", async () => { const server = await startTlsMcpServer() try { From 0b8837cc19e4b87d9c7fd4d435910f03f649b05e Mon Sep 17 00:00:00 2001 From: karup <8500529+karup@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:00:36 -0700 Subject: [PATCH 3/4] docs: note that fingerprint pins skip name matching --- services/www/src/docs/content/mcp-servers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/www/src/docs/content/mcp-servers.mdx b/services/www/src/docs/content/mcp-servers.mdx index 490cd574258b..d5e98071002e 100644 --- a/services/www/src/docs/content/mcp-servers.mdx +++ b/services/www/src/docs/content/mcp-servers.mdx @@ -235,7 +235,7 @@ Pin the exact certificate a server presents with `fingerprint`, formatted as `SH } ``` -OpenCode reads the server's certificate once, compares the fingerprint, and then trusts exactly that certificate for the connection. Get a fingerprint from the server with: +OpenCode compares the certificate the server presents against the fingerprint and then uses the fingerprint as the identity check for the connection. Name matching is not enforced, so this also works for appliance certificates without an IP SAN. Get a fingerprint from the server with: ```sh openssl s_client -connect 192.168.1.1:443 /dev/null | openssl x509 -noout -fingerprint -sha256 From 120c4384fa2169b62e8d563370e547e8289fbc8d Mon Sep 17 00:00:00 2001 From: karup <8500529+karup@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:29:50 -0700 Subject: [PATCH 4/4] fix(core): re-verify pinned certificates on every handshake --- packages/core/src/mcp/tls.ts | 35 ++++++------ packages/core/test/mcp-tls.test.ts | 56 ++++++++++++++++--- services/www/src/docs/content/mcp-servers.mdx | 2 +- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/packages/core/src/mcp/tls.ts b/packages/core/src/mcp/tls.ts index b079765ea877..6d87be1cf3c5 100644 --- a/packages/core/src/mcp/tls.ts +++ b/packages/core/src/mcp/tls.ts @@ -77,18 +77,21 @@ export function readCaFile(filePath: string, workspaceDir: string): string { /** * The trust rules a server's connections are subject to. `ca` is a PEM trust anchor bundle; - * `fingerprint` pins one exact certificate, making its SHA256 fingerprint the identity check. + * `fingerprint` pins one exact certificate, whose SHA256 fingerprint is re-checked on every + * handshake. */ -export type ResolvedTls = - | { readonly ca: string } - | { readonly fingerprint: string } +export type ResolvedTls = { + readonly ca: string + readonly fingerprint?: string +} /** * Resolve a server's TLS config into the trust rules for its connections. An undefined result * means "use the system trust store". A pinned fingerprint opens one pre-flight TLS connection; - * the presented certificate must match it exactly. Name matching is deliberately not enforced: - * LAN appliance certificates often omit the IP SANs a hostname check would require, and the - * fingerprint already identifies the certificate the user pinned. + * the presented certificate must match it exactly and becomes the trust anchor for the real + * connection, so chain verification only accepts that certificate. Name matching is deliberately + * not enforced: LAN appliance certificates often omit the IP SANs a hostname check would require, + * and the fingerprint already identifies the certificate the user pinned. */ export async function resolveTls( config: ConfigMCP.Tls, @@ -100,7 +103,7 @@ export async function resolveTls( const cert = await probeCertificate(url.hostname, port(url)) if (cert.fingerprint !== expected) throw new Error(`Certificate fingerprint mismatch.\nExpected: ${expected}\nGot: ${cert.fingerprint}`) - return { fingerprint: expected } + return { ca: cert.pem, fingerprint: expected } } if (config.ca_pem !== undefined) validatePemCert(config.ca_pem, "ca_pem config entry") const ca = [ @@ -115,24 +118,22 @@ export async function resolveTls( /** * Wrap a fetch so its requests apply the resolved TLS trust rules. The trust is scoped to the * returned fetch and does not affect other connections, including redirects it follows. A pinned - * fingerprint disables certificate and name validation on the real connection: the pre-flight - * comparison is the effective check on current runtimes, and the identity hook is the check on - * runtimes that honor it. + * fingerprint anchors chain verification to the pinned certificate itself, and the identity hook + * replaces the hostname check while re-checking the fingerprint on every handshake. */ export function withTls( resolved: ResolvedTls, base: (url: string | URL, init?: RequestInit) => Promise, ): (url: string | URL, init?: RequestInit) => Promise { - if ("fingerprint" in resolved) { - const fingerprint = resolved.fingerprint + const fingerprint = resolved.fingerprint + if (fingerprint !== undefined) { return (url, init) => base(url, { ...init, tls: { - rejectUnauthorized: false, - // Current runtimes ignore this callback's result when rejectUnauthorized is false, so - // the pre-flight comparison is the effective check; the callback is the check on - // runtimes that enforce it. + ca: resolved.ca, + // Replaces the hostname check: returning undefined accepts the certificate (even one + // without an IP SAN), returning an Error rejects it. checkServerIdentity: (_hostname: string, cert: PeerCertificate) => { const presented = createHash("sha256").update(cert.raw).digest("hex") if (presented !== fingerprint) diff --git a/packages/core/test/mcp-tls.test.ts b/packages/core/test/mcp-tls.test.ts index 292a8f796551..2a49381e3c3e 100644 --- a/packages/core/test/mcp-tls.test.ts +++ b/packages/core/test/mcp-tls.test.ts @@ -10,7 +10,7 @@ import { ConfigMCP } from "@opencode/schema/config/mcp" import { McpClient } from "@opencode/core/mcp/client" import { McpTls } from "@opencode/core/mcp/tls" import { hostEnvironmentLayer } from "./fixture/environment" -import { CERT, CERT_APPLIANCE, KEY_APPLIANCE, startTlsMcpServer, TLS_FIXTURE_DIR } from "./fixture/tls-server" +import { CERT, CERT_APPLIANCE, KEY, KEY_APPLIANCE, startTlsMcpServer, TLS_FIXTURE_DIR } from "./fixture/tls-server" const SAMPLE_CERT = `-----BEGIN CERTIFICATE----- MIIDXTCCAkWgAwIBAgIJALRNRw0Gx+9FMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV @@ -234,12 +234,16 @@ describe("resolveTls", () => { ) }) - test("accepts a matching fingerprint against the presented certificate", async () => { + test("resolves a matching fingerprint to the presented certificate as the trust anchor", async () => { const server = await startTlsMcpServer() try { - await expect( - McpTls.resolveTls({ fingerprint: FINGERPRINT }, import.meta.dir, new URL(server.url)), - ).resolves.toEqual({ fingerprint: FINGERPRINT }) + const resolved = await McpTls.resolveTls({ fingerprint: FINGERPRINT }, import.meta.dir, new URL(server.url)) + expect(resolved?.fingerprint).toBe(FINGERPRINT) + if (!resolved) throw new Error("resolveTls returned undefined") + const anchor = createHash("sha256") + .update(Buffer.from(resolved.ca.split("\n").slice(1, -1).join(""), "base64")) + .digest("hex") + expect(anchor).toBe(FINGERPRINT) } finally { await server.close() } @@ -287,17 +291,18 @@ describe("withTls", () => { expect((calls[0].init as BunFetchRequestInit).tls).toEqual({ ca: SAMPLE_CERT }) }) - test("disables certificate validation and installs the fingerprint check for a pin", async () => { + test("anchors the trust store to the pinned certificate and installs the fingerprint check", async () => { const calls: Array<{ init?: RequestInit }> = [] const base = ((_: string | URL, init?: RequestInit) => { calls.push({ init }) return Promise.resolve(new Response("ok")) }) as typeof fetch - await McpTls.withTls({ fingerprint: FINGERPRINT }, base)("https://127.0.0.1:8443/mcp") + await McpTls.withTls({ ca: CERT, fingerprint: FINGERPRINT }, base)("https://127.0.0.1:8443/mcp") const tls = (calls[0].init as BunFetchRequestInit).tls - expect(tls?.rejectUnauthorized).toBe(false) + expect(tls?.ca).toBe(CERT) + expect(tls?.rejectUnauthorized).toBeUndefined() const check = tls?.checkServerIdentity expect(typeof check).toBe("function") const der = Buffer.from(CERT.split("\n").slice(1, -1).join(""), "base64") @@ -403,7 +408,7 @@ describe("MCP connect over TLS", () => { const server = await startTlsMcpServer({ cert: CERT_APPLIANCE, key: KEY_APPLIANCE }) try { const resolved = await McpTls.resolveTls({ fingerprint: FINGERPRINT_APPLIANCE }, import.meta.dir, new URL(server.url)) - if (!resolved || !("fingerprint" in resolved) || resolved.fingerprint !== FINGERPRINT_APPLIANCE) + if (!resolved || resolved.fingerprint !== FINGERPRINT_APPLIANCE) throw new Error(`unexpected resolved tls: ${JSON.stringify(resolved)}`) // A bare GET is rejected by the streamable transport, so prove the trust with an initialize. const response = await McpTls.withTls(resolved, globalThis.fetch)(server.url, { @@ -472,4 +477,37 @@ describe("MCP connect over TLS", () => { await server.close() } }) + + test("rejects a certificate swapped after the first connect", async () => { + const probe = await startTlsMcpServer() + const port = probe.port + await probe.close() + let current = await startTlsMcpServer({ cert: CERT_APPLIANCE, key: KEY_APPLIANCE, port }) + try { + const url = new URL(current.url) + const resolved = await McpTls.resolveTls({ fingerprint: FINGERPRINT_APPLIANCE }, import.meta.dir, url) + if (!resolved || resolved.fingerprint !== FINGERPRINT_APPLIANCE) + throw new Error(`unexpected resolved tls: ${JSON.stringify(resolved)}`) + const fetchPinned = McpTls.withTls(resolved, globalThis.fetch) + const request = () => + fetchPinned(url, { + method: "POST", + headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "opencode", version: "1.0.0" } }, + }), + }) + expect((await request()).status).toBe(200) + // The pin is re-verified on every handshake: swap the certificate on the same port and + // the next request must fail, not just the first one. + await current.close() + current = await startTlsMcpServer({ cert: CERT, key: KEY, port }) + await expect(request()).rejects.toThrow(/self[- ]signed|fingerprint mismatch/i) + } finally { + await current.close() + } + }) }) diff --git a/services/www/src/docs/content/mcp-servers.mdx b/services/www/src/docs/content/mcp-servers.mdx index d5e98071002e..60bb2142db28 100644 --- a/services/www/src/docs/content/mcp-servers.mdx +++ b/services/www/src/docs/content/mcp-servers.mdx @@ -235,7 +235,7 @@ Pin the exact certificate a server presents with `fingerprint`, formatted as `SH } ``` -OpenCode compares the certificate the server presents against the fingerprint and then uses the fingerprint as the identity check for the connection. Name matching is not enforced, so this also works for appliance certificates without an IP SAN. Get a fingerprint from the server with: +OpenCode compares the certificate the server presents against the fingerprint and then anchors the connection to that exact certificate: every handshake verifies the chain against it and re-checks the fingerprint. Name matching is not enforced, so this also works for appliance certificates without an IP SAN. Get a fingerprint from the server with: ```sh openssl s_client -connect 192.168.1.1:443 /dev/null | openssl x509 -noout -fingerprint -sha256