Repository navigation
feat(mcp): support per-server TLS trust for remote MCP servers #60039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
karup
wants to merge
48
commits into
anomalyco:dev
Choose a base branch
from
karup:mcp-tls-config
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,002
−1
Open
Changes from all commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
584c70e
feat(mcp): support per-server TLS trust for remote MCP servers
karup 540b0ec
Merge branch 'dev' into mcp-tls-config
karup ece89a5
Merge branch 'dev' into mcp-tls-config
karup 70e84f0
Merge branch 'dev' into mcp-tls-config
karup 414b684
Merge branch 'dev' into mcp-tls-config
karup 16d8f54
Merge branch 'dev' into mcp-tls-config
karup 2c27162
Merge branch 'dev' into mcp-tls-config
karup 11286ee
Merge branch 'dev' into mcp-tls-config
karup 0b3f02a
Merge branch 'dev' into mcp-tls-config
karup aa72ed2
Merge branch 'dev' into mcp-tls-config
karup 0218a86
Merge branch 'dev' into mcp-tls-config
karup 8972042
fix(core): skip name matching for fingerprint-pinned TLS certificates
karup 0b8837c
docs: note that fingerprint pins skip name matching
karup f25ece6
Merge branch 'dev' into mcp-tls-config
karup 120c438
fix(core): re-verify pinned certificates on every handshake
karup 6e017a5
Merge branch 'dev' into mcp-tls-config
karup 6ca159d
Merge branch 'dev' into mcp-tls-config
karup a37bd9e
Merge branch 'dev' into mcp-tls-config
karup 4ee5a94
Merge branch 'dev' into mcp-tls-config
karup bea71d9
Merge branch 'dev' into mcp-tls-config
karup b1ed8e9
Merge branch 'dev' into mcp-tls-config
karup 134d0bf
Merge branch 'dev' into mcp-tls-config
karup 3c8d39e
Merge branch 'dev' into mcp-tls-config
karup 06a804f
Merge branch 'dev' into mcp-tls-config
karup 0935321
Merge branch 'dev' into mcp-tls-config
karup 3590ba4
Merge branch 'dev' into mcp-tls-config
karup 4f5510c
Merge branch 'dev' into mcp-tls-config
karup 221f0b9
Merge branch 'dev' into mcp-tls-config
karup 7fdb314
Merge branch 'dev' into mcp-tls-config
karup 6c27587
Merge branch 'dev' into mcp-tls-config
karup 8f5a2a8
Merge branch 'dev' into mcp-tls-config
karup 1d5111b
Merge branch 'dev' into mcp-tls-config
karup 12f8d5c
Merge branch 'dev' into mcp-tls-config
karup a45fc8c
Merge branch 'dev' into mcp-tls-config
karup 45ba66b
Merge branch 'dev' into mcp-tls-config
karup 2250fc4
Merge branch 'dev' into mcp-tls-config
karup 560d45b
Merge branch 'dev' into mcp-tls-config
karup 3c9f8c4
Merge branch 'dev' into mcp-tls-config
karup c2e77ce
Merge branch 'dev' into mcp-tls-config
karup d8e3bac
Merge branch 'dev' into mcp-tls-config
karup 045b8ab
Merge branch 'dev' into mcp-tls-config
karup e218290
Merge branch 'dev' into mcp-tls-config
karup 380b49a
Merge branch 'dev' into mcp-tls-config
karup 42b08e8
Merge branch 'dev' into mcp-tls-config
karup d6639b6
Merge branch 'dev' into mcp-tls-config
karup 15f739f
Merge branch 'dev' into mcp-tls-config
karup 360d0eb
Merge branch 'dev' into mcp-tls-config
karup 33677d0
Merge branch 'dev' into mcp-tls-config
karup File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,246 @@ | ||
| 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, { type PeerCertificate } 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) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The trust rules a server's connections are subject to. `ca` is a PEM trust anchor bundle; | ||
| * `fingerprint` pins one exact certificate, whose SHA256 fingerprint is re-checked on every | ||
| * handshake. | ||
| */ | ||
| 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 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, | ||
| workspaceDir: string, | ||
| url: URL, | ||
| ): Promise<ResolvedTls | undefined> { | ||
| 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 { ca: cert.pem, fingerprint: expected } | ||
| } | ||
| if (config.ca_pem !== undefined) validatePemCert(config.ca_pem, "ca_pem config entry") | ||
| const ca = [ | ||
| config.ca_pem, | ||
| config.ca_file !== undefined ? readCaFile(config.ca_file, workspaceDir) : undefined, | ||
| ] | ||
| .filter((part): part is string => part !== undefined) | ||
| .join("") | ||
| return ca === "" ? undefined : { ca } | ||
| } | ||
|
|
||
| /** | ||
| * 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 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<Response>, | ||
| ): (url: string | URL, init?: RequestInit) => Promise<Response> { | ||
| const fingerprint = resolved.fingerprint | ||
| if (fingerprint !== undefined) { | ||
| return (url, init) => | ||
| base(url, { | ||
| ...init, | ||
| tls: { | ||
| 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) | ||
| return new Error(`Certificate fingerprint mismatch.\nExpected: ${fingerprint}\nGot: ${presented}`) | ||
| }, | ||
| }, | ||
| } as BunFetchRequestInit) | ||
| } | ||
| return (url, init) => base(url, { ...init, tls: { ca: resolved.ca } } as BunFetchRequestInit) | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| 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 { | ||
| /** 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<Certificate> { | ||
| return new Promise<Certificate>((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` | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| 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") | ||
| // 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 | ||
| 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) | ||
| }, | ||
| } | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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----- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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----- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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----- |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.