From f56bea4530347648a21d058e3b7387e5c595a117 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 09:25:38 +0000 Subject: [PATCH] Open the origin directly, and trust the store curl actually reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by installing this on a real machine rather than by reading it. The proxy relayed every connection through the gateway. That 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: refuse alt.2600: key mismatch, presented ErIMn03cxhS+... refuse chovy.hacker: key mismatch, presented ErIMn03cxhS+... refuse seo.rank: key mismatch, presented ErIMn03cxhS+... The registry already publishes each name's target, so the proxy now dials the origin and falls back to the gateway only when there is no target. This changes nothing about trust: the pin still decides whether the connection lives, so a target pointed somewhere hostile fails the same check. It removes a hop that has to be configured exactly right to work at all. Separately, moshpit-trust covered browsers and not the system CA store, so `curl ` still failed on a machine that had been set up — which reads as the scheme being broken rather than one store being missed. It now installs into the distribution's anchor directory and rebuilds the bundle, with a read-back that checks the bundle really contains the root: update-ca-certificates ignores a file whose name does not end in .crt, silently and successfully, and that is exactly the failure worth catching. Verified on the box: all four live names now serve over the stock configuration, TLSv1.3 hybrid-pq, with no gateway override and no local pin file. Known limitation: an override supplies pins and no target, so an override-pinned name still relays through the gateway. Overrides are for private grids that may have no registry, so they deliberately skip it; letting an override carry its own target is the follow-up. Co-Authored-By: Claude Opus 5 --- lib/pins.ts | 22 ++- lib/proxy.ts | 51 ++++++- lib/trust.ts | 106 ++++++++++++++- tests/upstream-and-system-trust.test.ts | 172 ++++++++++++++++++++++++ 4 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 tests/upstream-and-system-trust.test.ts 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"); +});