Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions lib/pins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PinLookup | null>;
Expand Down Expand Up @@ -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)
: [];
Expand All @@ -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;
Expand Down
51 changes: 48 additions & 3 deletions lib/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
106 changes: 102 additions & 4 deletions lib/trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 <name>` 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. */
Expand All @@ -57,13 +76,26 @@ 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> = {}): TrustEnv {
return {
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);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<StoreStatus> {
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" };
Expand Down Expand Up @@ -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]);
Expand All @@ -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]);
Expand Down
Loading
Loading