diff --git a/lib/pins.ts b/lib/pins.ts index 1d71b6b..ae915b4 100644 --- a/lib/pins.ts +++ b/lib/pins.ts @@ -18,7 +18,20 @@ // between a pinning scheme people use and one they turn off. export type PinSource = "override" | "registry" | "tofu"; -export type PinLookup = { name: string; pins: string[]; source: PinSource }; +export type PinLookup = { + name: string; + pins: string[]; + source: PinSource; + /** + * Where the name's owner points it, when the registry says. + * + * Carried so the proxy can open the origin directly instead of relaying + * through the gateway. It is not trusted as an identity — the pin is still + * the only thing that decides whether the connection lives — it only says + * which address to dial. + */ + target?: string; +}; export type PinClient = { lookup(name: string): Promise; @@ -92,7 +105,7 @@ export function createPinClient(options: { } if (!res.ok) throw new Error(`registry responded ${res.status}`); - const json = (await res.json()) as { name?: unknown; pins?: unknown }; + const json = (await res.json()) as { name?: unknown; pins?: unknown; target?: unknown }; const pins = Array.isArray(json?.pins) ? json.pins.filter((p): p is string => typeof p === "string" && p.length > 0) : []; @@ -101,10 +114,15 @@ export function createPinClient(options: { return null; } + // Spread rather than `target: … : undefined`, so a name with no target + // has no `target` key at all. An own property set to undefined is not + // deep-equal to an absent one, and callers compare these. + const target = typeof json.target === "string" ? json.target.trim() : ""; const value: PinLookup = { name: typeof json.name === "string" ? json.name : name, pins, source: "registry", + ...(target ? { target } : {}), }; remember(name, value, ttlMs); return value; diff --git a/lib/proxy.ts b/lib/proxy.ts index 559c18e..1f7887d 100644 --- a/lib/proxy.ts +++ b/lib/proxy.ts @@ -30,6 +30,7 @@ // answering ALPNCallback from cache — worth doing, not worth blocking on. // HTTP/3 would not survive a TCP proxy regardless. +import { isIP } from "node:net"; import { createSecureContext, createServer, connect } from "node:tls"; import type { Server, TLSSocket } from "node:tls"; import type { LocalCa } from "./ca.ts"; @@ -152,6 +153,34 @@ export function createProxy(options: { void verifyAndPipe(name, browser); }); + /** + * Which host to open for a name: its own origin when the registry names one, + * the gateway otherwise. + * + * A target may carry a port (`example.com:8443`) and may be an IPv6 literal, + * which is why this is parsed rather than split on the first colon — + * `2604:a880::1` has plenty of colons and no port. + */ + function upstreamFor( + allowed: { target?: string } | null, + gatewayHost: string, + ): { host: string; port?: number } { + const target = allowed?.target?.trim(); + if (!target) return { host: gatewayHost }; + + const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(target); + if (bracketed) return { host: bracketed[1], port: bracketed[2] ? Number(bracketed[2]) : undefined }; + + // Bare IPv6 literal: colons belong to the address, not to a port. + if (isIP(target) === 6) return { host: target }; + + const colon = target.lastIndexOf(":"); + if (colon > 0 && /^\d+$/.test(target.slice(colon + 1))) { + return { host: target.slice(0, colon), port: Number(target.slice(colon + 1)) }; + } + return { host: target }; + } + async function verifyAndPipe(name: string, browser: TLSSocket) { const allowed = await options.pins.lookup(name); if (!allowed && !tofu) { @@ -160,10 +189,26 @@ export function createProxy(options: { return; } + // Straight to the origin when the registry says where it is, and only + // through the gateway otherwise. + // + // Relaying through the gateway requires it to pass the connection through + // by SNI (`ssl_preread`) rather than terminate it. Where it terminates — + // which is what pit.moshcode.sh does today — every name presents the + // gateway's own certificate, so the pin never matches and the proxy + // correctly refuses every site. Three different names refused for the same + // presented key is the signature of that. + // + // Dialling the target changes nothing about trust: the pin is still the + // only thing that decides whether the connection survives, so a target + // pointed somewhere hostile fails the same check as anything else. It only + // removes a hop that has to be configured exactly right to work at all. + const upstreamHost = upstreamFor(allowed, options.gatewayHost); const upstream = connect({ - host: options.gatewayHost, - port: gatewayPort, - // The SNI the gateway routes on. It is also the identity being pinned. + host: upstreamHost.host, + port: upstreamHost.port ?? gatewayPort, + // The SNI the origin (or the gateway) routes on. It is also the identity + // being pinned. servername: name, ALPNProtocols: ["http/1.1"], // Not "no verification" — different verification. The chain is diff --git a/lib/trust.ts b/lib/trust.ts index f77130e..0db4885 100644 --- a/lib/trust.ts +++ b/lib/trust.ts @@ -25,7 +25,7 @@ // and installing twice is a no-op rather than a duplicate nickname. import { execFile } from "node:child_process"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { homedir, platform as osPlatform } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -35,7 +35,26 @@ const execFileAsync = promisify(execFile); /** The nickname the root is filed under. Stable — it is how we find it again. */ export const NICKNAME = "Moshpit Local CA"; -export type StoreKind = "nss" | "macos-keychain"; +export type StoreKind = "nss" | "macos-keychain" | "ca-certificates"; + +/** + * Where a Linux distribution wants extra roots dropped, and what refreshes the + * bundle afterwards. The file is written into `dir`; the command rebuilds + * /etc/ssl/certs from it. + * + * This is the store `curl`, `wget`, `git` and Node read — none of which look at + * NSS. Covering only browsers meant `curl ` failed with a self-signed + * certificate error on a machine that had been "set up", which reads as the + * whole scheme being broken rather than as one store having been missed. + */ +export const CA_CERTIFICATES_DIRS: Array<{ dir: string; refresh: string }> = [ + // Debian, Ubuntu and derivatives. + { dir: "/usr/local/share/ca-certificates", refresh: "update-ca-certificates" }, + // Fedora, RHEL, CentOS, Rocky, Alma. + { dir: "/etc/pki/ca-trust/source/anchors", refresh: "update-ca-trust" }, + // Arch, and openSUSE via p11-kit. + { dir: "/etc/ca-certificates/trust-source/anchors", refresh: "update-ca-trust" }, +]; export type Store = { /** Stable id, used in output and in tests. */ @@ -57,6 +76,10 @@ export type TrustEnv = { exists: (path: string) => boolean; listDir: (path: string) => string[]; run: Runner; + /** Empty string when the file cannot be read, so callers never have to catch. */ + readFile: (path: string) => string; + copyFile: (from: string, to: string) => void; + removeFile: (path: string) => void; }; export function defaultEnv(overrides: Partial = {}): TrustEnv { @@ -64,6 +87,15 @@ export function defaultEnv(overrides: Partial = {}): TrustEnv { platform: osPlatform(), home: homedir(), exists: existsSync, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } + }, + copyFile: (from, to) => copyFileSync(from, to), + removeFile: (path) => rmSync(path, { force: true }), listDir: (path) => { try { return readdirSync(path); @@ -107,6 +139,20 @@ export function discoverStores(env: TrustEnv = defaultEnv()): Store[] { needsRoot: false, }); } + + // The system bundle. First match wins: a machine has one of these, and + // writing a root into a second distribution's directory would leave a file + // nothing ever reads. + const anchors = CA_CERTIFICATES_DIRS.find((candidate) => env.exists(candidate.dir)); + if (anchors) { + stores.push({ + id: "ca-certificates", + kind: "ca-certificates", + path: anchors.dir, + label: "curl, wget, git and anything using the system store", + needsRoot: true, + }); + } } for (const profile of firefoxProfiles(env)) { @@ -231,11 +277,50 @@ function osReleaseId(env: TrustEnv): string { } } +/** The file this root is written as, inside a distribution's anchor directory. */ +export const ANCHOR_FILENAME = "moshpit-local-ca.crt"; + +/** + * Bundles a refresh command regenerates. Checked so "installed" means the + * bundle actually contains the root, not merely that a file was dropped in a + * directory — `update-ca-certificates` skips a file whose name does not end in + * `.crt`, and exits zero while doing so. + */ +const SYSTEM_BUNDLES = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/ca-bundle.pem", +]; + +/** The base64 body of a PEM, which is what to look for inside a bundle. */ +function pemBody(pem: string): string { + return pem + .split("\n") + .filter((line) => line.trim() && !line.startsWith("-----")) + .join("") + .trim(); +} + +function inSystemBundle(env: TrustEnv, anchor: string): boolean { + const body = pemBody(env.readFile(anchor)); + // A short or absent body would match everything; treat it as not installed. + if (body.length < 64) return false; + const needle = body.slice(0, 64); + return SYSTEM_BUNDLES.some((bundle) => env.exists(bundle) && pemBody(env.readFile(bundle)).includes(needle)); +} + export type StoreStatus = { store: Store; installed: boolean; detail: string }; /** Whether this root is already trusted in `store`. Never writes. */ export async function status(store: Store, env: TrustEnv = defaultEnv()): Promise { try { + if (store.kind === "ca-certificates") { + const anchor = join(store.path, ANCHOR_FILENAME); + if (!env.exists(anchor)) return { store, installed: false, detail: "not present" }; + return inSystemBundle(env, anchor) + ? { store, installed: true, detail: "already trusted" } + : { store, installed: false, detail: "the file is there but the system bundle does not contain it" }; + } if (store.kind === "nss") { await env.run("certutil", ["-d", `sql:${store.path}`, "-L", "-n", NICKNAME]); return { store, installed: true, detail: "already trusted" }; @@ -266,7 +351,14 @@ export async function install( if (before.installed) return { store, ok: true, changed: false, detail: "already trusted" }; try { - if (store.kind === "nss") { + if (store.kind === "ca-certificates") { + const refresh = CA_CERTIFICATES_DIRS.find((c) => c.dir === store.path)?.refresh; + if (!refresh) return { store, ok: false, changed: false, detail: `no refresh command known for ${store.path}` }; + // The .crt suffix is load-bearing on Debian: update-ca-certificates + // ignores anything else in this directory, silently and successfully. + env.copyFile(certPath, join(store.path, ANCHOR_FILENAME)); + await env.run(refresh, []); + } else if (store.kind === "nss") { // "C,," — trusted to issue server certificates, and nothing else. Not // "CT,c,c" and not a mail or code-signing trust bit; this root has one job. await env.run("certutil", ["-d", `sql:${store.path}`, "-A", "-t", "C,,", "-n", NICKNAME, "-i", certPath]); @@ -291,7 +383,13 @@ export async function uninstall(store: Store, env: TrustEnv = defaultEnv()): Pro if (!before.installed) return { store, ok: true, changed: false, detail: "was not present" }; try { - if (store.kind === "nss") { + if (store.kind === "ca-certificates") { + const refresh = CA_CERTIFICATES_DIRS.find((c) => c.dir === store.path)?.refresh; + env.removeFile(join(store.path, ANCHOR_FILENAME)); + // Without the refresh the anchor is gone but the bundle still trusts it, + // which is the worst of the three states. + if (refresh) await env.run(refresh, []); + } else if (store.kind === "nss") { await env.run("certutil", ["-d", `sql:${store.path}`, "-D", "-n", NICKNAME]); } else { await env.run("security", ["delete-certificate", "-c", NICKNAME, store.path]); diff --git a/tests/upstream-and-system-trust.test.ts b/tests/upstream-and-system-trust.test.ts new file mode 100644 index 0000000..81bad7f --- /dev/null +++ b/tests/upstream-and-system-trust.test.ts @@ -0,0 +1,172 @@ +// Two defects found by installing this on a real machine and watching it fail. +// +// 1. The proxy relayed every connection through the gateway, which only works +// if the gateway passes TLS through by SNI. pit.moshcode.sh terminates it, +// so every name presented the gateway's own certificate and the proxy +// refused all of them — correctly, and uselessly. Three different names +// refused for one identical presented key is the signature. +// +// 2. `moshpit-trust` covered browsers and not the system CA store, so `curl` +// still failed on a machine that had been "set up". +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createPinClient } from "../lib/pins.ts"; +import { + ANCHOR_FILENAME, + CA_CERTIFICATES_DIRS, + NICKNAME, + defaultEnv, + discoverStores, + install, + status, + uninstall, + type TrustEnv, +} from "../lib/trust.ts"; + +// ---- the target the registry publishes reaches the pin client ---- + +const registryReturning = (body: unknown) => + createPinClient({ + base: "https://registry.test", + fetchImpl: (async () => new Response(JSON.stringify(body), { + status: 200, headers: { "content-type": "application/json" }, + })) as typeof fetch, + }); + +test("a name's target is carried through, so the origin can be dialled directly", async () => { + const client = registryReturning({ name: "a.hacker", pins: ["AAA="], target: "origin.example:8443" }); + const found = await client.lookup("a.hacker"); + assert.equal(found?.target, "origin.example:8443"); +}); + +test("a name with no target has no target key at all", async () => { + // Not `target: undefined` — an own property set to undefined is not + // deep-equal to an absent one, and that difference broke a passing test. + const client = registryReturning({ name: "a.hacker", pins: ["AAA="] }); + const found = await client.lookup("a.hacker"); + assert.equal(Object.hasOwn(found as object, "target"), false); +}); + +test("a blank target is treated as no target, not as an empty host", async () => { + const client = registryReturning({ name: "a.hacker", pins: ["AAA="], target: " " }); + assert.equal(Object.hasOwn((await client.lookup("a.hacker")) as object, "target"), false); +}); + +// ---- the system CA store ---- + +function fakeLinux(overrides: Partial = {}): { env: TrustEnv; files: Map; ran: string[] } { + const files = new Map(); + const ran: string[] = []; + const env: TrustEnv = { + ...defaultEnv(), + platform: "linux", + home: "/home/nobody", + exists: (p) => files.has(p), + listDir: () => [], + readFile: (p) => files.get(p) ?? "", + copyFile: (from, to) => files.set(to, files.get(from) ?? ""), + removeFile: (p) => { files.delete(p); }, + run: async (file) => { ran.push(file); return { stdout: "", stderr: "" }; }, + ...overrides, + }; + return { env, files, ran }; +} + +const PEM = `-----BEGIN CERTIFICATE-----\n${"QUJDRA".repeat(20)}\n-----END CERTIFICATE-----\n`; + +test("the system store is discovered on Linux, and is the one curl reads", () => { + const { env, files } = fakeLinux(); + files.set("/usr/local/share/ca-certificates", ""); + const store = discoverStores(env).find((s) => s.kind === "ca-certificates"); + assert.ok(store, "a Linux machine must offer the store curl, wget and git use"); + assert.equal(store.needsRoot, true, "writing a system root needs an administrator"); + assert.match(store.label, /curl/); +}); + +test("only one distribution's anchor directory is used", () => { + const { env, files } = fakeLinux(); + for (const c of CA_CERTIFICATES_DIRS) files.set(c.dir, ""); + const found = discoverStores(env).filter((s) => s.kind === "ca-certificates"); + assert.equal(found.length, 1, + "writing into a second distribution's directory leaves a file nothing reads"); +}); + +test("installing writes a .crt anchor and refreshes the bundle", async () => { + const { env, files, ran } = fakeLinux(); + files.set("/usr/local/share/ca-certificates", ""); + files.set("/ca.crt", PEM); + files.set("/etc/ssl/certs/ca-certificates.crt", ""); + + // The refresh is what puts the root in the bundle; model that. + const withRefresh: TrustEnv = { + ...env, + run: async (file) => { + ran.push(file); + if (file === "update-ca-certificates") { + files.set("/etc/ssl/certs/ca-certificates.crt", files.get(`/usr/local/share/ca-certificates/${ANCHOR_FILENAME}`) ?? ""); + } + return { stdout: "", stderr: "" }; + }, + }; + + const store = discoverStores(env).find((s) => s.kind === "ca-certificates")!; + const result = await install(store, "/ca.crt", withRefresh); + + assert.equal(result.ok, true, result.detail); + assert.ok(files.has(`/usr/local/share/ca-certificates/${ANCHOR_FILENAME}`), + "the filename must end in .crt — update-ca-certificates ignores anything else, silently"); + assert.ok(ran.includes("update-ca-certificates"), "an anchor nothing rebuilt is not trusted"); +}); + +test("a refresh that does not reach the bundle is reported as a failure", async () => { + // The exact shape of the bug this replaces: the write succeeds, the command + // exits zero, and nothing trusts the root. Reporting success there is worse + // than failing. + const { env, files } = fakeLinux(); + files.set("/usr/local/share/ca-certificates", ""); + files.set("/ca.crt", PEM); + files.set("/etc/ssl/certs/ca-certificates.crt", "unrelated content"); + + const store = discoverStores(env).find((s) => s.kind === "ca-certificates")!; + const result = await install(store, "/ca.crt", env); + + assert.equal(result.ok, false, "an install nothing trusts must not report success"); + assert.equal(result.changed, false); + // The post-install read-back is what catches this, and it is shared with the + // other store kinds. The bundle-specific wording is status()'s job, asserted + // separately below — that is what someone runs to find out why. + assert.match(result.detail, /does not show it/); +}); + +test("uninstalling removes the anchor and rebuilds, in that order", async () => { + const { env, files, ran } = fakeLinux(); + files.set("/usr/local/share/ca-certificates", ""); + files.set(`/usr/local/share/ca-certificates/${ANCHOR_FILENAME}`, PEM); + files.set("/etc/ssl/certs/ca-certificates.crt", PEM); + + const store = discoverStores(env).find((s) => s.kind === "ca-certificates")!; + assert.equal((await status(store, env)).installed, true, "precondition: it is trusted"); + + const result = await uninstall(store, env); + assert.equal(result.ok, true, result.detail); + assert.equal(files.has(`/usr/local/share/ca-certificates/${ANCHOR_FILENAME}`), false); + assert.ok(ran.includes("update-ca-certificates"), + "an anchor removed without a rebuild leaves the bundle still trusting it"); +}); + +test("status is honest when the file is present but the bundle is not rebuilt", async () => { + const { env, files } = fakeLinux(); + files.set("/usr/local/share/ca-certificates", ""); + files.set(`/usr/local/share/ca-certificates/${ANCHOR_FILENAME}`, PEM); + files.set("/etc/ssl/certs/ca-certificates.crt", "something else entirely"); + + const store = discoverStores(env).find((s) => s.kind === "ca-certificates")!; + const state = await status(store, env); + assert.equal(state.installed, false); + assert.match(state.detail, /bundle/); +}); + +test("the nickname is unchanged, so an older install is still found", () => { + assert.equal(NICKNAME, "Moshpit Local CA"); +});