diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 344439a..fe62156 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -197,6 +197,7 @@ export const CORE_CLI_COMMANDS = [ ["--port ", "port for the bridge", "5354"], ["--registry ", "registry to resolve against", "https://pit.moshcode.sh"], ["--no-trust", "with enable: route names but skip the local CA", ""], + ["--no-proxy", "with enable: answer origins rather than the local proxy", ""], ], examples: [ ["sudo moshcode dns enable", "route Moshpit endings here"], diff --git a/src/dns-system.mjs b/src/dns-system.mjs index 6ba060a..6e43984 100644 --- a/src/dns-system.mjs +++ b/src/dns-system.mjs @@ -373,13 +373,17 @@ export async function daemonStatus(path = pidfilePath()) { * not survive a reboot. `moshcode dns status` says so plainly rather than * letting someone discover it when their names stop resolving. */ -export async function startDaemon({ port, registryBase, path = pidfilePath(), entry }) { +export async function startDaemon({ port, registryBase, path = pidfilePath(), entry, proxy = null }) { const existing = await daemonStatus(path); if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true }; await mkdir(dirname(path), { recursive: true }); const args = [entry, "dns", "start", "--port", String(port)]; if (registryBase) args.push("--registry", registryBase); + // Passed at spawn time because it is what the resolver answers with, not + // something it can be told later — there is no channel to a detached daemon + // short of restarting it, which is why `enable` decides this before starting. + if (proxy) args.push("--proxy", proxy); const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" }); child.unref(); diff --git a/src/dns.mjs b/src/dns.mjs index 78de619..4bccea9 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -695,6 +695,111 @@ export function proxyReachable(address, port = 443, { connect = null, timeoutMs }); } +/** The root moshpit-proxy signs with. Its leaves are how the proxy is recognised. */ +export const PROXY_ROOT_CN = "Moshpit Local CA"; + +/** + * Is the thing on that address *our proxy*, or merely something on port 443? + * + * `proxyReachable` answers the second question, and on one common class of + * machine the two answers differ in the worst possible way. An origin runs + * nginx on `0.0.0.0:443`, which covers loopback — so a bare connect succeeds, + * proxy mode is turned on, and every live Moshpit name on the machine is + * pointed at a web server that knows nothing about them. That is not a + * certificate problem, it is every name on the machine serving the wrong site + * at once, and the connect probe cannot see it coming. + * + * So this asks the question that actually distinguishes them: complete a TLS + * handshake and look at who issued the certificate. The proxy mints a leaf per + * name from the root it generated on this machine, so the issuer is that root. + * Anything else — nginx with the origin's own self-signed certificate, some + * unrelated service — is issued by something else and is refused. + * + * `rejectUnauthorized` is off deliberately, and it is not a hole: nothing is + * sent, the peer certificate is read rather than trusted, and the only thing + * accepted from it is the issuer name. Verifying properly would require the + * root to already be installed, which is a step that has not happened yet at + * the point this runs. + */ +export async function proxyServes(address, name, { + port = PROXY_PORT, + timeoutMs = 2500, + tlsConnect = null, +} = {}) { + const connectImpl = tlsConnect || (await import("node:tls")).connect; + return new Promise((resolve) => { + let socket; + const done = (result) => { + try { socket?.destroy(); } catch { /* already gone */ } + resolve(result); + }; + try { + socket = connectImpl({ + host: address, + port, + servername: name, + rejectUnauthorized: false, + // The proxy forces http/1.1; offering nothing keeps this a pure + // handshake rather than a protocol negotiation that could be declined. + ALPNProtocols: ["http/1.1"], + }); + // Not unref'd, for the reason proxyReachable spells out: this timer is the + // only guarantee the promise settles. + const timer = setTimeout(() => done({ ok: false, why: "timed out" }), timeoutMs); + socket.once("secureConnect", () => { + clearTimeout(timer); + const cert = socket.getPeerCertificate?.() || {}; + const issuer = cert.issuer?.CN || ""; + if (issuer === PROXY_ROOT_CN) return done({ ok: true, issuer }); + done({ + ok: false, + issuer, + // Named as what it means rather than what was seen: "issuer is + // chovy.hacker" is a fact, "something else owns 443" is the reason + // proxy mode must stay off. + why: issuer + ? `something other than the proxy owns ${address}:${port} — it served a certificate issued by ${JSON.stringify(issuer)}` + : `something other than the proxy owns ${address}:${port}`, + }); + }); + socket.once("error", (err) => { + clearTimeout(timer); + done({ ok: false, why: err?.code || err?.message || "connection failed" }); + }); + } catch (err) { + resolve({ ok: false, why: err?.message || "connection failed" }); + } + }); +} + +/** + * Which loopback addresses have the proxy behind them, if any. + * + * Both families are asked because answering one of them wrongly is an outage: + * a v6-only answer for a v4-only listener is a refused connection that reads as + * the site being down. `addressAnswer` handles the asymmetry; this just reports + * what is actually there. + */ +export async function findLocalProxy(name, { candidates = ["127.0.0.1", "::1"], ...options } = {}) { + const reachable = []; + let why = null; + for (const address of candidates) { + const result = await proxyServes(address, name, options); + if (result.ok) reachable.push(address); + // Keep the most informative refusal: "something else owns 443" is worth + // saying out loud, where "ECONNREFUSED" just means no proxy is installed. + else if (result.issuer && !why) why = result.why; + } + return { + found: reachable.length > 0, + why, + address: { + v4: reachable.find((a) => isIP(a) === 4) || null, + v6: reachable.find((a) => isIP(a) === 6) || null, + }, + }; +} + export async function addressAnswer(name, options = {}) { const { parkingAddress, wantsV6 = false, proxyAddress = null } = options; const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra }); @@ -2088,6 +2193,10 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine --no-trust with enable: route names but skip the local CA. They will resolve and then fail TLS, which is the state this flag exists to leave you in deliberately. + --no-proxy with enable: answer each name's origin rather than the local + pinned-TLS proxy. Only the proxy can hand a stock client a + certificate it will accept, so this is the other half of the + same deliberate breakage. The registry speaks HTTP, not DNS, so nothing outside a browser can reach a Moshpit name until this bridge is running and your resolver points at it. @@ -2123,6 +2232,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { bridgeStatus = daemonStatus, startBridge = startDaemon, proxyReachableImpl = proxyReachable, + findLocalProxyImpl = findLocalProxy, autoTrustImpl = createAutoTrust, stopBridge = stopDaemon, dropins = readDropins, @@ -2723,9 +2833,65 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // serve is silently shadowed by the bridge it said would not be started. // Honoring the note is the whole of the fix. const reusing = cleared.holder && cleared.holderForwards ? cleared.holder : null; + + // Proxy mode, decided here because the bridge cannot be told later: what a + // resolver answers with is fixed when it starts. + // + // This is the step that was missing, and its absence is why the whole + // feature read as broken. Everything else was built — the proxy verifies + // origins against registry pins and re-signs with a root `dns enable` + // installs, and `addressAnswer` knows how to point names at it — but + // nothing ever turned it on, so names resolved straight to their origin and + // a stock client got a certificate no CA had signed. Trust was installed + // for a proxy that was never on the path. + // + // Refusing is the safe direction and the default: with proxy mode on and + // nothing behind it, every Moshpit name on the machine resolves and then + // refuses the connection. + let proxyAddress = null; + if (reusing) { + // A bridge this run did not start keeps whatever mode it was started + // with: `startDaemon` decides "already running" from our pidfile, and + // there is no channel to a detached daemon to change its mind. So the + // probe is skipped rather than run and then discarded — announcing a + // proxy and retracting it two lines later is worse than not looking. + out(" -- the bridge already running was not started by this run, so it keeps its own"); + out(" mode — to pick up proxy mode: moshcode dns disable && moshcode dns enable"); + } else if (!rest.includes("--no-proxy")) { + const probeName = moshpitProbe || ""; + if (!probeName) { + out(" -- proxy mode not checked — no Moshpit name to probe with"); + } else { + const local = await findLocalProxyImpl(probeName); + if (local.found) { + proxyAddress = local.address; + const at = [local.address.v4, local.address.v6].filter(Boolean).join(", "); + out(` ok pinned-TLS proxy on ${at}:${PROXY_PORT} — every live name will answer there`); + } else if (local.why) { + // The origin case, and the one worth naming precisely. A machine that + // serves Moshpit names has nginx on 443, so the proxy cannot be on the + // path here and pointing names at loopback would hand all of them to + // a web server that has never heard of them. + out(` -- ${local.why}`); + out(" proxy mode stays off — names will answer their origin."); + } else { + out(" -- no pinned-TLS proxy on this machine — names will answer their origin"); + out(" a stock client cannot verify those: https://github.com/profullstack/moshpit-proxy"); + } + } + } + const started = reusing ? { started: false, pid: reusing.pid, alreadyRunning: true, reused: true } - : await startBridge({ port: wanted, registryBase, entry: cliEntry() }); + : await startBridge({ + port: wanted, + registryBase, + entry: cliEntry(), + // v4 by preference: `dns start --proxy` takes one address and probes + // both families itself, so handing it the v4 loopback lets it find ::1 + // too rather than pinning the answer to one family. + proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null, + }); out(started.reused ? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one` : started.alreadyRunning diff --git a/src/trust.mjs b/src/trust.mjs index c4a88ee..fc4c3c3 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -482,16 +482,53 @@ export function leafPath(name, { platform = process.platform } = {}) { : `/usr/local/share/ca-certificates/moshpit-${safe}.crt`; } +/** + * Is this certificate marked as a certificate authority? + * + * Read with node's X509 parser rather than by grepping openssl's text, because + * the answer decides whether a key gets authority over the whole clearnet and + * "CA:FALSE" is a substring of nothing but is adjacent to plenty. + * + * A certificate carrying no basicConstraints at all answers false: absent is + * not the same as asserted, and RFC 5280 §4.2.1.9 treats such a certificate as + * an end entity. + */ +export async function isCertificateAuthority(pem) { + const crypto = await import("node:crypto"); + return new crypto.X509Certificate(pem).ca === true; +} + /** * What `trust ` should do, given what the socket served and what the * registry says about it. * * Pure, so the refusal path is testable without a network or a trust store. */ -export function leafTrustPlan({ name, pin, published, platform = process.platform } = {}) { +export function leafTrustPlan({ name, pin, published, platform = process.platform, ca = false } = {}) { const accepted = pinAccepted(pin, published); if (!accepted.ok) return { ok: false, refused: true, why: accepted.why }; + // A certificate installed here is installed as a *trust anchor*, and an + // anchor marked CA:TRUE may issue for any name in the world. The SAN says + // what the certificate speaks for; it says nothing about what a key trusted + // as an authority may go on to sign — so `subjectAltName=DNS:seo.rank` on a + // CA:TRUE certificate is not the bound it looks like, and trusting one would + // hand its holder google.com along with their own name. + // + // This is the same hole `requireNameConstraints` exists to close on the root + // path, arriving by the other door. It went unnoticed because openssl's + // `req -x509` defaults to CA:TRUE, so every origin set up before that default + // was overridden serves exactly the shape that must be refused — and it looks + // identical to a correct one until someone trusts it. + if (ca) { + return { + ok: false, + refused: true, + kind: "ca", + why: `${name} serves a certificate marked CA:TRUE — trusted directly, its key could vouch for any name`, + }; + } + const file = leafPath(name, { platform }); if (!file) return { ok: false, why: `${name} is not a name that can be written to a file` }; @@ -499,10 +536,11 @@ export function leafTrustPlan({ name, pin, published, platform = process.platfor ok: true, why: accepted.why, file, - // A self-signed leaf is its own trust anchor, and its SAN limits it to this - // one name — so trusting it vouches for `seo.rank` and nothing else. That - // is a far smaller grant than a CA, which is why this path needs no - // name constraints argument to be defensible. + // With CA:FALSE established above, a self-signed leaf is its own trust + // anchor and its SAN limits it to this one name — so trusting it vouches + // for `seo.rank` and nothing else. That is a far smaller grant than a CA, + // which is why this path needs no name-constraints argument to be + // defensible. It is only true because of the check above. refresh: platform === "darwin" ? { command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file] } : { command: "update-ca-certificates", args: [] }, @@ -571,10 +609,25 @@ export async function trustName(name, out, deps = {}) { return 1; } - const plan = leafTrustPlan({ name, pin, published, platform }); + // Read off the certificate rather than assumed: an origin set up before + // `setup-origin.sh` overrode openssl's default serves CA:TRUE, and that is + // the one shape this must not install. + const ca = await isCertificateAuthority(served.stdout).catch(() => true); + + const plan = leafTrustPlan({ name, pin, published, platform, ca }); if (!plan.ok) { out(`REFUSED — ${plan.why}`); - if (plan.refused) { + if (plan.kind === "ca") { + // A refusal with no way forward is a refusal people route around, and + // this one has a cheap way forward that costs nothing anywhere else: the + // pin is over the key, so re-issuing the certificate from the same key + // leaves the published pin untouched. Nothing has to be republished and + // no client holding the old pin breaks. + out(" its SAN says what it speaks for, not what it may sign — an anchor"); + out(" marked CA:TRUE is not limited to the name printed on it."); + out(" re-issue it as CA:FALSE; the key is reused, so the pin does not move:"); + out(` sudo sh scripts/setup-origin.sh ${name} # from moshpit-proxy`); + } else if (plan.refused) { out(` served ${pin}`); out(published.length ? ` pinned ${published.join("\n ")}` : " pinned (none)"); out(" moshcode will not trust a certificate the registry does not vouch for."); diff --git a/test/dns-enable-rollback.test.mjs b/test/dns-enable-rollback.test.mjs index b8614c7..996c812 100644 --- a/test/dns-enable-rollback.test.mjs +++ b/test/dns-enable-rollback.test.mjs @@ -454,6 +454,10 @@ function noSystem() { preflight: async () => ({ ok: true, blockers: [], conflicts: [], holder: null }), verify: async () => ({ ok: true, checks: [] }), bridgeStatus: async () => ({ running: false, pid: null, stale: false }), + // No proxy, which is the state these tests were written in. Stubbed rather + // than left to the real probe, which would open a TLS connection to + // whatever holds 443 on the machine running the suite. + findLocalProxyImpl: async () => ({ found: false, why: null, address: { v4: null, v6: null } }), startBridge: async () => ({ started: true, pid: 1, alreadyRunning: false }), stopBridge: async () => ({ stopped: true, reason: null }), dropins: async () => [], diff --git a/test/dns-proxy-mode.test.mjs b/test/dns-proxy-mode.test.mjs index 9e07553..2c8323a 100644 --- a/test/dns-proxy-mode.test.mjs +++ b/test/dns-proxy-mode.test.mjs @@ -17,8 +17,15 @@ import test from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import dgram from "node:dgram"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import tls from "node:tls"; +import { execFileSync } from "node:child_process"; -import { addressAnswer, dnsCommand, proxyReachable, PROXY_PORT } from "../src/dns.mjs"; +import { + PROXY_PORT, PROXY_ROOT_CN, addressAnswer, dnsCommand, findLocalProxy, proxyReachable, proxyServes, +} from "../src/dns.mjs"; /** Hold a UDP port so the bind fails and `start` returns instead of serving. */ function holdUdp() { @@ -214,3 +221,209 @@ test("--proxy with a host name refuses instead of NODATA'ing every live name", a await held.release(); } }); + +/* ------------------------------------------------------------ certificates -*/ + +/** + * A leaf issued by a root named `cn`, the way moshpit-proxy issues one per name. + * + * Real openssl rather than a fixture: the whole decision reads + * `getPeerCertificate().issuer.CN` off a completed handshake, and a hand-built + * object would prove only that the test sets the field the code looks at. + */ +function chain(leafName, issuerCn) { + const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "proxy-mode-")); + const p = (f) => path.join(dir, f); + const quiet = { stdio: "ignore" }; + + execFileSync("openssl", [ + "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", p("ca.key"), "-out", p("ca.crt"), "-days", "1", + "-subj", `/CN=${issuerCn}`, "-addext", "basicConstraints=critical,CA:TRUE", + ], quiet); + + execFileSync("openssl", [ + "req", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", p("leaf.key"), "-out", p("leaf.csr"), "-subj", `/CN=${leafName}`, + ], quiet); + + fsSync.writeFileSync(p("ext.cnf"), `subjectAltName=DNS:${leafName}\nbasicConstraints=critical,CA:FALSE\n`); + execFileSync("openssl", [ + "x509", "-req", "-in", p("leaf.csr"), "-CA", p("ca.crt"), "-CAkey", p("ca.key"), + "-CAcreateserial", "-out", p("leaf.crt"), "-days", "1", "-extfile", p("ext.cnf"), + ], quiet); + + return { cert: fsSync.readFileSync(p("leaf.crt")), key: fsSync.readFileSync(p("leaf.key")), dir }; +} + +/** A self-signed certificate: an origin's own, which is what nginx serves. */ +function selfSigned(name) { + const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "origin-")); + const p = (f) => path.join(dir, f); + execFileSync("openssl", [ + "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", p("k.pem"), "-out", p("c.pem"), "-days", "1", + "-subj", `/CN=${name}`, "-addext", `subjectAltName=DNS:${name}`, + "-addext", "basicConstraints=critical,CA:FALSE", + ], { stdio: "ignore" }); + return { cert: fsSync.readFileSync(p("c.pem")), key: fsSync.readFileSync(p("k.pem")) }; +} + +/** A TLS server on an ephemeral loopback port, closed when the test ends. */ +async function serve(t, { cert, key }) { + const server = tls.createServer({ cert, key }, (socket) => socket.end()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + return server.address().port; +} + +/* ------------------------------------------------------------------ probe --*/ + +test("the proxy is recognised by who issued the certificate it serves", async (t) => { + const port = await serve(t, chain("chovy.hacker", PROXY_ROOT_CN)); + const result = await proxyServes("127.0.0.1", "chovy.hacker", { port }); + assert.equal(result.ok, true); + assert.equal(result.issuer, PROXY_ROOT_CN); +}); + +test("nginx on an origin is refused, however reachable it is", async (t) => { + // The case that makes a bare connect probe unsafe. This server answers, and + // answering is exactly what would have turned proxy mode on and pointed every + // name on the machine at a web server that knows nothing about them. + const port = await serve(t, selfSigned("chovy.hacker")); + const result = await proxyServes("127.0.0.1", "chovy.hacker", { port }); + assert.equal(result.ok, false); + assert.match(result.why, /something other than the proxy owns/); + assert.equal(result.issuer, "chovy.hacker", "the origin's certificate is its own issuer"); +}); + +test("nothing listening is a refusal, not a crash", async () => { + // Port 1 on loopback: reserved, never bound. + const result = await proxyServes("127.0.0.1", "chovy.hacker", { port: 1, timeoutMs: 1500 }); + assert.equal(result.ok, false); + assert.ok(result.why); + assert.equal(result.issuer, undefined, "a connection that never completed has no issuer to report"); +}); + +test("findLocalProxy reports the family it actually found, not both", async (t) => { + const port = await serve(t, chain("chovy.hacker", PROXY_ROOT_CN)); + const found = await findLocalProxy("chovy.hacker", { candidates: ["127.0.0.1"], port }); + assert.equal(found.found, true); + assert.equal(found.address.v4, "127.0.0.1"); + // Answering ::1 for a v4-only listener is a refused connection that reads as + // the site being down. + assert.equal(found.address.v6, null); +}); + +test("findLocalProxy keeps the informative refusal, not the boring one", async (t) => { + const port = await serve(t, selfSigned("chovy.hacker")); + const found = await findLocalProxy("chovy.hacker", { candidates: ["127.0.0.1"], port }); + assert.equal(found.found, false); + // "something else owns 443" is worth printing; ECONNREFUSED just means no + // proxy is installed and has its own, softer message. + assert.match(found.why, /something other than the proxy owns/); +}); + +/* ------------------------------------------------------------ dns enable ---*/ + +function enableDeps(extra = {}) { + return { + tlds: async () => ["hacker"], + safety: async () => ({ safe: true, upstreams: ["1.1.1.1"], why: "no bridge is running yet" }), + preflight: async () => ({ ok: true, blockers: [], conflicts: [], holder: null }), + verify: async () => ({ ok: true, checks: [] }), + bridgeStatus: async () => ({ running: false, pid: null, stale: false }), + stopBridge: async () => ({ stopped: true, reason: null }), + dropins: async () => [], + readManifest: async () => null, + manifestFile: path.join(os.tmpdir(), "moshcode-proxy-mode-manifest.json"), + uid: 0, + ...extra, + }; +} + +test("a proxy that is really there is switched on, without being asked", async () => { + // The whole point. Everything else was already built — the proxy checks + // origins against registry pins, `dns enable` installs the root it signs with + // — and none of it was on the path, because nothing ever turned this on. + const lines = []; + let startedWith = null; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => ({ found: true, why: null, address: { v4: "127.0.0.1", v6: "::1" } }), + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + assert.equal(startedWith.proxy, "127.0.0.1"); + assert.match(lines.join("\n"), /pinned-TLS proxy on 127\.0\.0\.1, ::1:443/); +}); + +test("no proxy means names answer their origin, and it says why that is not enough", async () => { + const lines = []; + let startedWith = null; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => ({ found: false, why: null, address: { v4: null, v6: null } }), + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + // Refusing is the safe direction: proxy mode with nothing behind it resolves + // every Moshpit name and then refuses every connection. + assert.equal(startedWith.proxy, null); + assert.match(lines.join("\n"), /no pinned-TLS proxy on this machine/); +}); + +test("something else on 443 is named, rather than being quietly treated as the proxy", async () => { + const lines = []; + let startedWith = null; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => ({ + found: false, + why: 'something other than the proxy owns 127.0.0.1:443 — it served a certificate issued by "chovy.hacker"', + address: { v4: null, v6: null }, + }), + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + assert.equal(startedWith.proxy, null); + const said = lines.join("\n"); + assert.match(said, /something other than the proxy owns/); + assert.match(said, /proxy mode stays off/); +}); + +test("--no-proxy skips the probe entirely", async () => { + const lines = []; + let probed = false; + let startedWith = null; + await dnsCommand(["enable", "--no-proxy"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => { probed = true; return { found: true, why: null, address: { v4: "127.0.0.1", v6: null } }; }, + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + assert.equal(probed, false, "the flag means do not look, not look and ignore"); + assert.equal(startedWith.proxy, null); +}); + +test("a bridge this run did not start is not claimed to be proxying", async () => { + // `startDaemon` decides "already running" from our pidfile, so a bridge + // started by systemd or by hand keeps whatever mode it has. Printing + // "proxying every live name" over it is the exact species of lie this whole + // change exists to stop telling. + const lines = []; + let startedWith = "untouched"; + let probed = false; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + preflight: async () => ({ + ok: true, blockers: [], conflicts: [], + holder: { pid: 4242, command: "bun" }, holderForwards: true, + }), + findLocalProxyImpl: async () => { probed = true; return { found: true, why: null, address: { v4: "127.0.0.1", v6: null } }; }, + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + const said = lines.join("\n"); + assert.equal(startedWith, "untouched", "the running bridge is reused, not restarted"); + assert.equal(probed, false, "and not probed, since the answer could not be acted on"); + assert.match(said, /keeps its own/); + // Announcing a proxy and retracting it two lines later is worse than not + // looking: the reader has already believed the first line. + assert.doesNotMatch(said, /every live name will answer there/); +}); diff --git a/test/trust.test.mjs b/test/trust.test.mjs index 3a2facc..c9feab9 100644 --- a/test/trust.test.mjs +++ b/test/trust.test.mjs @@ -436,8 +436,15 @@ test("the refusal a session prints carries its remedy", async () => { import { fetchCertificateCommand, leafPath, leafTrustPlan, pinAccepted, pinFromCertificate, trustName } from "../src/trust.mjs"; -/** A real self-signed leaf, the shape a Moshpit origin serves. */ -function leaf(cn = "seo.rank") { +/** + * A real self-signed leaf, the shape a Moshpit origin serves. + * + * `ca` is a parameter because both shapes are real. CA:FALSE is what + * `setup-origin.sh` issues and the only shape that may be trusted directly; + * CA:TRUE is what openssl's `req -x509` produces by default, which is what + * every origin created before that default was overridden is still serving. + */ +function leaf(cn = "seo.rank", { ca = false } = {}) { try { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "leaf-")); const key = path.join(dir, "k.pem"); @@ -445,6 +452,7 @@ function leaf(cn = "seo.rank") { execFileSync("openssl", [ "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-keyout", key, "-out", crt, "-days", "1", "-subj", `/CN=${cn}`, "-addext", `subjectAltName=DNS:${cn}`, + "-addext", `basicConstraints=critical,CA:${ca ? "TRUE" : "FALSE"}`, ], { stdio: "ignore" }); const pem = fs.readFileSync(crt, "utf8"); fs.rmSync(dir, { recursive: true, force: true }); @@ -564,6 +572,66 @@ test("without root it says so instead of failing obscurely on the write", async assert.match(lines.join("\n"), /needs root/); }); +test("a certificate marked CA:TRUE is refused however good its pin is", async () => { + // The dangerous shape, and the one that looked safe: a matching pin proves + // the registry vouches for the *key*, not that trusting it is bounded. An + // anchor marked CA:TRUE may issue for any name, so installing one would hand + // its holder the clearnet — the SAN on it constrains nothing it signs. + const pem = leaf("seo.rank", { ca: true }); + if (!pem) return; + const pin = await pinFromCertificate(pem); + const lines = []; + const ran = []; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => { + ran.push(c); + return a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }; + }, + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: [pin] }) }), + writeFile: async () => assert.fail("a CA must never be installed as an anchor, pin or no pin"), + uid: 0, platform: "linux", + }); + assert.equal(code, 1); + assert.match(lines.join("\n"), /REFUSED/); + assert.match(lines.join("\n"), /CA:TRUE/); + assert.ok(!ran.includes("update-ca-certificates"), "and the store is never refreshed"); +}); + +test("the CA refusal says how to get past it, since the fix costs no pin change", async () => { + // A gate with no way forward is a gate people route around. Re-issuing from + // the same key leaves the published pin untouched, so the remedy is free — + // and saying so is the difference between "fix it" and "give up". + const pem = leaf("seo.rank", { ca: true }); + if (!pem) return; + const pin = await pinFromCertificate(pem); + const lines = []; + await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => (a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }), + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: [pin] }) }), + writeFile: async () => {}, + uid: 0, platform: "linux", + }); + const said = lines.join("\n"); + assert.match(said, /CA:FALSE/); + assert.match(said, /setup-origin\.sh seo\.rank/); + assert.match(said, /pin does not move/); +}); + +test("an unreadable certificate is treated as a CA, not waved through", async () => { + // The safe direction to fail in. If the parse throws, whether this is an + // authority is unknown — and "unknown" must not install an anchor. + const lines = []; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => (a.join(" ").includes("s_client") + ? { ok: true, stdout: "-----BEGIN CERTIFICATE-----\nnot a certificate\n-----END CERTIFICATE-----\n", stderr: "" } + : { ok: true, stdout: "", stderr: "" }), + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: ["whatever="] }) }), + writeFile: async () => assert.fail("nothing may be written when the certificate cannot be read"), + uid: 0, platform: "linux", + }); + assert.equal(code, 1); +}); + test("no name asks for one rather than guessing", async () => { const lines = []; assert.equal(await trustName("", (l) => lines.push(l), {}), 1);