Skip to content

Commit 7f576a2

Browse files
ralyodioclaude
andauthored
fix(moshpit): put the pinned-TLS proxy on the path, and stop trusting CAs as leaves (#343)
* fix(trust): refuse to install a CA:TRUE certificate as a trust anchor `dns trust <name>` installed whatever the socket served, provided the registry published a matching pin. The pin proves the registry vouches for that *key*; it says nothing about whether trusting it is bounded. A certificate installed here is installed as a trust anchor, and an anchor marked CA:TRUE may issue for any name. The code claimed the opposite — "its SAN limits it to this one name" — but a SAN describes what a certificate speaks for, not what a key trusted as an authority may sign. This is the same hole requireNameConstraints closes on the root path, arriving by the other door. It went unnoticed because openssl's `req -x509` defaults to CA:TRUE, so every origin created by setup-origin.sh serves exactly the shape that must be refused, and it is indistinguishable from a correct one until someone trusts it. The refusal names a remedy that costs nothing: re-issue as CA:FALSE reusing the key, and the published pin does not move. A gate with no way forward is a gate people route around. An unreadable certificate is treated as a CA — the safe direction to fail in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(dns): turn proxy mode on in `dns enable`, so a stock client just works `addressAnswer` could already point every live name at the local pinned-TLS proxy, `dns start --proxy` could already switch it on, and `dns enable` already installed the root the proxy signs with. Nothing ever connected them. So names resolved straight to their origin, a stock client got a certificate no CA had signed, and the trust store was populated for a proxy that was never on the path — which reads, correctly, as "this is still broken". `dns enable` now probes for the proxy and starts the bridge in proxy mode when it finds one. The probe is a TLS handshake, not a connect. `proxyReachable` answers "is something listening", and on one common class of machine the two answers differ in the worst way: an origin runs nginx on 0.0.0.0:443, which covers loopback, so a connect succeeds and proxy mode would point every live Moshpit name on the machine at a web server that has never heard of them. That is not a certificate problem, it is every name serving the wrong site at once. So `proxyServes` completes a handshake and checks who issued the certificate. The proxy mints a leaf per name from the root it generated here; nginx serves the origin's own self-signed certificate, issued by itself. Nothing is trusted in the process — the peer certificate is read, not verified, and only the issuer name is taken from it. Refusing is the default in every uncertain case. Proxy mode with nothing behind it resolves every name and then refuses every connection, which looks like the sites are down while `dig` stays healthy. A bridge this run did not start keeps its own mode, 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. `--no-proxy` opts out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a947b98 commit 7f576a2

7 files changed

Lines changed: 521 additions & 12 deletions

File tree

src/cli-schema.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export const CORE_CLI_COMMANDS = [
197197
["--port <n>", "port for the bridge", "5354"],
198198
["--registry <url>", "registry to resolve against", "https://pit.moshcode.sh"],
199199
["--no-trust", "with enable: route names but skip the local CA", ""],
200+
["--no-proxy", "with enable: answer origins rather than the local proxy", ""],
200201
],
201202
examples: [
202203
["sudo moshcode dns enable", "route Moshpit endings here"],

src/dns-system.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,13 +373,17 @@ export async function daemonStatus(path = pidfilePath()) {
373373
* not survive a reboot. `moshcode dns status` says so plainly rather than
374374
* letting someone discover it when their names stop resolving.
375375
*/
376-
export async function startDaemon({ port, registryBase, path = pidfilePath(), entry }) {
376+
export async function startDaemon({ port, registryBase, path = pidfilePath(), entry, proxy = null }) {
377377
const existing = await daemonStatus(path);
378378
if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true };
379379

380380
await mkdir(dirname(path), { recursive: true });
381381
const args = [entry, "dns", "start", "--port", String(port)];
382382
if (registryBase) args.push("--registry", registryBase);
383+
// Passed at spawn time because it is what the resolver answers with, not
384+
// something it can be told later — there is no channel to a detached daemon
385+
// short of restarting it, which is why `enable` decides this before starting.
386+
if (proxy) args.push("--proxy", proxy);
383387

384388
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
385389
child.unref();

src/dns.mjs

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,111 @@ export function proxyReachable(address, port = 443, { connect = null, timeoutMs
695695
});
696696
}
697697

698+
/** The root moshpit-proxy signs with. Its leaves are how the proxy is recognised. */
699+
export const PROXY_ROOT_CN = "Moshpit Local CA";
700+
701+
/**
702+
* Is the thing on that address *our proxy*, or merely something on port 443?
703+
*
704+
* `proxyReachable` answers the second question, and on one common class of
705+
* machine the two answers differ in the worst possible way. An origin runs
706+
* nginx on `0.0.0.0:443`, which covers loopback — so a bare connect succeeds,
707+
* proxy mode is turned on, and every live Moshpit name on the machine is
708+
* pointed at a web server that knows nothing about them. That is not a
709+
* certificate problem, it is every name on the machine serving the wrong site
710+
* at once, and the connect probe cannot see it coming.
711+
*
712+
* So this asks the question that actually distinguishes them: complete a TLS
713+
* handshake and look at who issued the certificate. The proxy mints a leaf per
714+
* name from the root it generated on this machine, so the issuer is that root.
715+
* Anything else — nginx with the origin's own self-signed certificate, some
716+
* unrelated service — is issued by something else and is refused.
717+
*
718+
* `rejectUnauthorized` is off deliberately, and it is not a hole: nothing is
719+
* sent, the peer certificate is read rather than trusted, and the only thing
720+
* accepted from it is the issuer name. Verifying properly would require the
721+
* root to already be installed, which is a step that has not happened yet at
722+
* the point this runs.
723+
*/
724+
export async function proxyServes(address, name, {
725+
port = PROXY_PORT,
726+
timeoutMs = 2500,
727+
tlsConnect = null,
728+
} = {}) {
729+
const connectImpl = tlsConnect || (await import("node:tls")).connect;
730+
return new Promise((resolve) => {
731+
let socket;
732+
const done = (result) => {
733+
try { socket?.destroy(); } catch { /* already gone */ }
734+
resolve(result);
735+
};
736+
try {
737+
socket = connectImpl({
738+
host: address,
739+
port,
740+
servername: name,
741+
rejectUnauthorized: false,
742+
// The proxy forces http/1.1; offering nothing keeps this a pure
743+
// handshake rather than a protocol negotiation that could be declined.
744+
ALPNProtocols: ["http/1.1"],
745+
});
746+
// Not unref'd, for the reason proxyReachable spells out: this timer is the
747+
// only guarantee the promise settles.
748+
const timer = setTimeout(() => done({ ok: false, why: "timed out" }), timeoutMs);
749+
socket.once("secureConnect", () => {
750+
clearTimeout(timer);
751+
const cert = socket.getPeerCertificate?.() || {};
752+
const issuer = cert.issuer?.CN || "";
753+
if (issuer === PROXY_ROOT_CN) return done({ ok: true, issuer });
754+
done({
755+
ok: false,
756+
issuer,
757+
// Named as what it means rather than what was seen: "issuer is
758+
// chovy.hacker" is a fact, "something else owns 443" is the reason
759+
// proxy mode must stay off.
760+
why: issuer
761+
? `something other than the proxy owns ${address}:${port} — it served a certificate issued by ${JSON.stringify(issuer)}`
762+
: `something other than the proxy owns ${address}:${port}`,
763+
});
764+
});
765+
socket.once("error", (err) => {
766+
clearTimeout(timer);
767+
done({ ok: false, why: err?.code || err?.message || "connection failed" });
768+
});
769+
} catch (err) {
770+
resolve({ ok: false, why: err?.message || "connection failed" });
771+
}
772+
});
773+
}
774+
775+
/**
776+
* Which loopback addresses have the proxy behind them, if any.
777+
*
778+
* Both families are asked because answering one of them wrongly is an outage:
779+
* a v6-only answer for a v4-only listener is a refused connection that reads as
780+
* the site being down. `addressAnswer` handles the asymmetry; this just reports
781+
* what is actually there.
782+
*/
783+
export async function findLocalProxy(name, { candidates = ["127.0.0.1", "::1"], ...options } = {}) {
784+
const reachable = [];
785+
let why = null;
786+
for (const address of candidates) {
787+
const result = await proxyServes(address, name, options);
788+
if (result.ok) reachable.push(address);
789+
// Keep the most informative refusal: "something else owns 443" is worth
790+
// saying out loud, where "ECONNREFUSED" just means no proxy is installed.
791+
else if (result.issuer && !why) why = result.why;
792+
}
793+
return {
794+
found: reachable.length > 0,
795+
why,
796+
address: {
797+
v4: reachable.find((a) => isIP(a) === 4) || null,
798+
v6: reachable.find((a) => isIP(a) === 6) || null,
799+
},
800+
};
801+
}
802+
698803
export async function addressAnswer(name, options = {}) {
699804
const { parkingAddress, wantsV6 = false, proxyAddress = null } = options;
700805
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
20882193
--no-trust with enable: route names but skip the local CA. They will
20892194
resolve and then fail TLS, which is the state this flag exists
20902195
to leave you in deliberately.
2196+
--no-proxy with enable: answer each name's origin rather than the local
2197+
pinned-TLS proxy. Only the proxy can hand a stock client a
2198+
certificate it will accept, so this is the other half of the
2199+
same deliberate breakage.
20912200
20922201
The registry speaks HTTP, not DNS, so nothing outside a browser can reach a
20932202
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 = {}) {
21232232
bridgeStatus = daemonStatus,
21242233
startBridge = startDaemon,
21252234
proxyReachableImpl = proxyReachable,
2235+
findLocalProxyImpl = findLocalProxy,
21262236
autoTrustImpl = createAutoTrust,
21272237
stopBridge = stopDaemon,
21282238
dropins = readDropins,
@@ -2723,9 +2833,65 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
27232833
// serve is silently shadowed by the bridge it said would not be started.
27242834
// Honoring the note is the whole of the fix.
27252835
const reusing = cleared.holder && cleared.holderForwards ? cleared.holder : null;
2836+
2837+
// Proxy mode, decided here because the bridge cannot be told later: what a
2838+
// resolver answers with is fixed when it starts.
2839+
//
2840+
// This is the step that was missing, and its absence is why the whole
2841+
// feature read as broken. Everything else was built — the proxy verifies
2842+
// origins against registry pins and re-signs with a root `dns enable`
2843+
// installs, and `addressAnswer` knows how to point names at it — but
2844+
// nothing ever turned it on, so names resolved straight to their origin and
2845+
// a stock client got a certificate no CA had signed. Trust was installed
2846+
// for a proxy that was never on the path.
2847+
//
2848+
// Refusing is the safe direction and the default: with proxy mode on and
2849+
// nothing behind it, every Moshpit name on the machine resolves and then
2850+
// refuses the connection.
2851+
let proxyAddress = null;
2852+
if (reusing) {
2853+
// A bridge this run did not start keeps whatever mode it was started
2854+
// with: `startDaemon` decides "already running" from our pidfile, and
2855+
// there is no channel to a detached daemon to change its mind. So the
2856+
// probe is skipped rather than run and then discarded — announcing a
2857+
// proxy and retracting it two lines later is worse than not looking.
2858+
out(" -- the bridge already running was not started by this run, so it keeps its own");
2859+
out(" mode — to pick up proxy mode: moshcode dns disable && moshcode dns enable");
2860+
} else if (!rest.includes("--no-proxy")) {
2861+
const probeName = moshpitProbe || "";
2862+
if (!probeName) {
2863+
out(" -- proxy mode not checked — no Moshpit name to probe with");
2864+
} else {
2865+
const local = await findLocalProxyImpl(probeName);
2866+
if (local.found) {
2867+
proxyAddress = local.address;
2868+
const at = [local.address.v4, local.address.v6].filter(Boolean).join(", ");
2869+
out(` ok pinned-TLS proxy on ${at}:${PROXY_PORT} — every live name will answer there`);
2870+
} else if (local.why) {
2871+
// The origin case, and the one worth naming precisely. A machine that
2872+
// serves Moshpit names has nginx on 443, so the proxy cannot be on the
2873+
// path here and pointing names at loopback would hand all of them to
2874+
// a web server that has never heard of them.
2875+
out(` -- ${local.why}`);
2876+
out(" proxy mode stays off — names will answer their origin.");
2877+
} else {
2878+
out(" -- no pinned-TLS proxy on this machine — names will answer their origin");
2879+
out(" a stock client cannot verify those: https://github.com/profullstack/moshpit-proxy");
2880+
}
2881+
}
2882+
}
2883+
27262884
const started = reusing
27272885
? { started: false, pid: reusing.pid, alreadyRunning: true, reused: true }
2728-
: await startBridge({ port: wanted, registryBase, entry: cliEntry() });
2886+
: await startBridge({
2887+
port: wanted,
2888+
registryBase,
2889+
entry: cliEntry(),
2890+
// v4 by preference: `dns start --proxy` takes one address and probes
2891+
// both families itself, so handing it the v4 loopback lets it find ::1
2892+
// too rather than pinning the answer to one family.
2893+
proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null,
2894+
});
27292895
out(started.reused
27302896
? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one`
27312897
: started.alreadyRunning

src/trust.mjs

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -482,27 +482,65 @@ export function leafPath(name, { platform = process.platform } = {}) {
482482
: `/usr/local/share/ca-certificates/moshpit-${safe}.crt`;
483483
}
484484

485+
/**
486+
* Is this certificate marked as a certificate authority?
487+
*
488+
* Read with node's X509 parser rather than by grepping openssl's text, because
489+
* the answer decides whether a key gets authority over the whole clearnet and
490+
* "CA:FALSE" is a substring of nothing but is adjacent to plenty.
491+
*
492+
* A certificate carrying no basicConstraints at all answers false: absent is
493+
* not the same as asserted, and RFC 5280 §4.2.1.9 treats such a certificate as
494+
* an end entity.
495+
*/
496+
export async function isCertificateAuthority(pem) {
497+
const crypto = await import("node:crypto");
498+
return new crypto.X509Certificate(pem).ca === true;
499+
}
500+
485501
/**
486502
* What `trust <name>` should do, given what the socket served and what the
487503
* registry says about it.
488504
*
489505
* Pure, so the refusal path is testable without a network or a trust store.
490506
*/
491-
export function leafTrustPlan({ name, pin, published, platform = process.platform } = {}) {
507+
export function leafTrustPlan({ name, pin, published, platform = process.platform, ca = false } = {}) {
492508
const accepted = pinAccepted(pin, published);
493509
if (!accepted.ok) return { ok: false, refused: true, why: accepted.why };
494510

511+
// A certificate installed here is installed as a *trust anchor*, and an
512+
// anchor marked CA:TRUE may issue for any name in the world. The SAN says
513+
// what the certificate speaks for; it says nothing about what a key trusted
514+
// as an authority may go on to sign — so `subjectAltName=DNS:seo.rank` on a
515+
// CA:TRUE certificate is not the bound it looks like, and trusting one would
516+
// hand its holder google.com along with their own name.
517+
//
518+
// This is the same hole `requireNameConstraints` exists to close on the root
519+
// path, arriving by the other door. It went unnoticed because openssl's
520+
// `req -x509` defaults to CA:TRUE, so every origin set up before that default
521+
// was overridden serves exactly the shape that must be refused — and it looks
522+
// identical to a correct one until someone trusts it.
523+
if (ca) {
524+
return {
525+
ok: false,
526+
refused: true,
527+
kind: "ca",
528+
why: `${name} serves a certificate marked CA:TRUE — trusted directly, its key could vouch for any name`,
529+
};
530+
}
531+
495532
const file = leafPath(name, { platform });
496533
if (!file) return { ok: false, why: `${name} is not a name that can be written to a file` };
497534

498535
return {
499536
ok: true,
500537
why: accepted.why,
501538
file,
502-
// A self-signed leaf is its own trust anchor, and its SAN limits it to this
503-
// one name — so trusting it vouches for `seo.rank` and nothing else. That
504-
// is a far smaller grant than a CA, which is why this path needs no
505-
// name constraints argument to be defensible.
539+
// With CA:FALSE established above, a self-signed leaf is its own trust
540+
// anchor and its SAN limits it to this one name — so trusting it vouches
541+
// for `seo.rank` and nothing else. That is a far smaller grant than a CA,
542+
// which is why this path needs no name-constraints argument to be
543+
// defensible. It is only true because of the check above.
506544
refresh: platform === "darwin"
507545
? { command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file] }
508546
: { command: "update-ca-certificates", args: [] },
@@ -571,10 +609,25 @@ export async function trustName(name, out, deps = {}) {
571609
return 1;
572610
}
573611

574-
const plan = leafTrustPlan({ name, pin, published, platform });
612+
// Read off the certificate rather than assumed: an origin set up before
613+
// `setup-origin.sh` overrode openssl's default serves CA:TRUE, and that is
614+
// the one shape this must not install.
615+
const ca = await isCertificateAuthority(served.stdout).catch(() => true);
616+
617+
const plan = leafTrustPlan({ name, pin, published, platform, ca });
575618
if (!plan.ok) {
576619
out(`REFUSED — ${plan.why}`);
577-
if (plan.refused) {
620+
if (plan.kind === "ca") {
621+
// A refusal with no way forward is a refusal people route around, and
622+
// this one has a cheap way forward that costs nothing anywhere else: the
623+
// pin is over the key, so re-issuing the certificate from the same key
624+
// leaves the published pin untouched. Nothing has to be republished and
625+
// no client holding the old pin breaks.
626+
out(" its SAN says what it speaks for, not what it may sign — an anchor");
627+
out(" marked CA:TRUE is not limited to the name printed on it.");
628+
out(" re-issue it as CA:FALSE; the key is reused, so the pin does not move:");
629+
out(` sudo sh scripts/setup-origin.sh ${name} # from moshpit-proxy`);
630+
} else if (plan.refused) {
578631
out(` served ${pin}`);
579632
out(published.length ? ` pinned ${published.join("\n ")}` : " pinned (none)");
580633
out(" moshcode will not trust a certificate the registry does not vouch for.");

test/dns-enable-rollback.test.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ function noSystem() {
454454
preflight: async () => ({ ok: true, blockers: [], conflicts: [], holder: null }),
455455
verify: async () => ({ ok: true, checks: [] }),
456456
bridgeStatus: async () => ({ running: false, pid: null, stale: false }),
457+
// No proxy, which is the state these tests were written in. Stubbed rather
458+
// than left to the real probe, which would open a TLS connection to
459+
// whatever holds 443 on the machine running the suite.
460+
findLocalProxyImpl: async () => ({ found: false, why: null, address: { v4: null, v6: null } }),
457461
startBridge: async () => ({ started: true, pid: 1, alreadyRunning: false }),
458462
stopBridge: async () => ({ stopped: true, reason: null }),
459463
dropins: async () => [],

0 commit comments

Comments
 (0)