From 74950146a5d4b84f23d859b660d9e7a7a81a7e52 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 9 Aug 2026 16:50:39 +0000 Subject: [PATCH] fix(origin): issue leaves as CA:FALSE, and trust them on the box that serves them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openssl req -x509` defaults to basicConstraints=CA:TRUE, and setup-origin.sh never overrode it. Every origin this script has ever created is therefore serving a certificate that is marked as a certificate authority. That matters because this certificate is meant to be trusted *directly* — it is its own anchor, which is the whole point of a pinned self-signed origin. An anchor marked CA:TRUE may issue for any name in the world. The SAN bounds what the certificate speaks for; it does not bound what a key trusted as an authority may go on to sign. So trusting one of these to reach `chovy.hacker` also hands its holder google.com. CA:FALSE with a single-name SAN is the shape that makes direct trust a bounded grant. Re-running fixes an already-published certificate for free: the key is reused and the pin is over the key, so the registry needs no update and no client holding the old pin breaks. Also here: - `--all` re-issues every name the box already has a key for, so repairing a fleet is one command with nothing to type and mistype. - the certificate is trusted on this machine once it is issued. The machine serving a Moshpit name is usually one somebody browses it from, and until now `curl https://` failed to verify there. The pinned-TLS proxy cannot fix that case: it works by owning 443 on loopback, and on an origin nginx already has 443 — a second bind gets EADDRINUSE. Trusting the leaf needs no port. - a name is checked against a hostname charset before it becomes a path under /etc/ssl, /etc/nginx and /usr/local/share/ca-certificates, as root. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup-origin.sh | 125 ++++++++++++++++++++++++-- tests/setup-origin.test.ts | 176 +++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 tests/setup-origin.test.ts diff --git a/scripts/setup-origin.sh b/scripts/setup-origin.sh index 56288ed..4c2c472 100755 --- a/scripts/setup-origin.sh +++ b/scripts/setup-origin.sh @@ -5,8 +5,15 @@ # # Generates a self-signed key pair for the name, writes an nginx server block # from nginx/moshpit-origin.conf, reloads, then connects back to itself and -# proves the name now answers with the key it just made. Ends by printing the -# pin to publish. +# proves the name now answers with the key it just made. Trusts the result on +# this machine, so the box that serves the name can also open it. Ends by +# printing the pin to publish. +# +# Re-run it whenever you like. The key is reused, so the pin does not move and +# the registry needs to hear nothing about it -- which is what makes repairing +# an already-published certificate free: +# +# sudo sh scripts/setup-origin.sh --all # every name this box serves # # Self-signed is the design, not a shortcut. No CA will issue for a Moshpit TLD, # so identity comes from the registry publishing SHA-256(SubjectPublicKeyInfo) @@ -35,6 +42,7 @@ API_KEY="${MOSHPIT_API_KEY:-}" REGISTRY="${MOSHPIT_REGISTRY:-https://app.moshcode.sh}" TARGET="" DRY_RUN=0 +TRUST_LOCAL=1 RED=''; BOLD=''; DIM=''; OFF='' if [ -t 2 ]; then RED=$(printf '\033[31m'); BOLD=$(printf '\033[1m'); DIM=$(printf '\033[2m'); OFF=$(printf '\033[0m'); fi @@ -46,8 +54,10 @@ have() { command -v "$1" >/dev/null 2>&1; } usage() { cat >&2 < [options] +usage: setup-origin.sh [options] + --all re-issue every name this box already has a key for + --no-trust do not trust the certificate on this machine --dry-run write nothing, print what would happen --days certificate lifetime (default: $DAYS) --webroot site files (default: /var/www/) @@ -68,10 +78,35 @@ EOF # dies telling you that `--help` is not a Moshpit name. case "$NAME" in -h|--help) usage; exit 0 ;; esac +# `--all` re-issues every name this box already serves, which is what makes +# repairing a fleet of CA:TRUE certificates one command rather than one command +# per name — and the names are already on disk, so there is nothing to type and +# nothing to get wrong. Every key is reused, so no pin moves and the registry +# does not need to hear about any of this. +# +# Done by re-invoking rather than by looping the body: each name gets the same +# validation, the same nginx reload and the same proof-of-serving it would get +# on its own, instead of a second code path that drifts from the first. +if [ "$NAME" = "--all" ]; then + shift + found=0 + for _key in "$CERTDIR"/*.key; do + [ -f "$_key" ] || continue # no match: the glob stayed literal + _name=$(basename "$_key" .key) + found=$((found + 1)) + step "$_name" + sh "$0" "$_name" "$@" || die "$_name failed — stopping before the rest" + done + [ "$found" != "0" ] || die "no keys in $CERTDIR — nothing to re-issue (name a site instead of --all)" + exit 0 +fi + shift 2>/dev/null || true while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1 ;; + --no-trust) TRUST_LOCAL=0 ;; + --all) die "--all goes first: sh $0 --all [options]" ;; --days) DAYS="${2:?--days needs a number}"; shift ;; --webroot) WEBROOT="${2:?--webroot needs a path}"; shift ;; --api-key) API_KEY="${2:?--api-key needs a token}"; shift ;; @@ -88,6 +123,14 @@ case "$NAME" in *.*) ;; *) die "'$NAME' does not look like a Moshpit name" ;; esac +# The name becomes a path -- under $CERTDIR, under $SITEDIR, and (below) under +# /usr/local/share/ca-certificates. A `/` or a `..` in it would write somewhere +# nobody asked for, as root. Nothing legal is lost by refusing them: a hostname +# is letters, digits, dots and dashes. +case "$NAME" in + *[!a-zA-Z0-9.-]* | .* | *..*) + die "'$NAME' is not a hostname — letters, digits, dots and dashes only" ;; +esac have openssl || die "openssl is required" # Caught here rather than after the certificate exists, so a typo does not leave @@ -134,13 +177,31 @@ fi CRT="$CERTDIR/$NAME.crt" KEY="$CERTDIR/$NAME.key" +# `openssl req -x509` defaults to basicConstraints=CA:TRUE, and that default is +# actively harmful here. This certificate is meant to be trusted directly — it +# is its own anchor, which is the whole point of a pinned self-signed origin — +# and a trust anchor marked CA:TRUE may issue for *any* name. The SAN limits +# what this certificate speaks for; it does not limit what a key trusted as a CA +# can go on to sign. So a client that trusted a CA:TRUE origin certificate would +# be handing that key authority over google.com, not over one Moshpit name. +# +# CA:FALSE plus a single-name SAN is the shape that makes direct trust a small, +# bounded grant: it vouches for this name and can vouch for nothing else. +# `moshcode dns trust` refuses the CA:TRUE shape for exactly this reason. +LEAF_EXT='basicConstraints=critical,CA:FALSE' +LEAF_USE='keyUsage=critical,digitalSignature,keyEncipherment' +LEAF_EKU='extendedKeyUsage=serverAuth' + if [ -f "$KEY" ]; then # Reusing the key is the point: the pin is over the key, so a certificate can - # be regenerated as often as you like and the published pin stays valid. + # be regenerated as often as you like and the published pin stays valid. It is + # also what makes fixing an already-issued CA:TRUE certificate free — re-run + # this and the pin the registry publishes does not move. say " ${DIM}key already exists — reusing it so the published pin stays valid${OFF}" if [ "$DRY_RUN" = "0" ]; then openssl req -x509 -new -nodes -key "$KEY" -sha256 -days "$DAYS" \ - -subj "/CN=$NAME" -addext "subjectAltName=DNS:$NAME" -out "$CRT" + -subj "/CN=$NAME" -addext "subjectAltName=DNS:$NAME" \ + -addext "$LEAF_EXT" -addext "$LEAF_USE" -addext "$LEAF_EKU" -out "$CRT" fi else if [ "$DRY_RUN" = "0" ]; then @@ -148,6 +209,7 @@ else -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ -sha256 -days "$DAYS" \ -subj "/CN=$NAME" -addext "subjectAltName=DNS:$NAME" \ + -addext "$LEAF_EXT" -addext "$LEAF_USE" -addext "$LEAF_EKU" \ -keyout "$KEY" -out "$CRT" chmod 600 "$KEY" fi @@ -224,6 +286,59 @@ if [ "$DRY_RUN" = "0" ]; then esac fi +# ------------------------------------------------------- trust it on this box + +# The machine that serves a Moshpit name is also, usually, a machine somebody +# browses it from — and until now `curl https://` on the origin itself +# failed to verify, which reads as "this site is broken" rather than "no CA will +# ever sign for this ending". +# +# The pinned-TLS proxy is the general answer to that, but it cannot be the +# answer *here*: it works by owning port 443 on loopback, and on an origin nginx +# already has 443. Two listeners cannot share it — a second bind gets EADDRINUSE +# — so on this one class of machine the proxy can never be on the path. +# +# Trusting the certificate directly needs no port and no proxy, and CA:FALSE +# above is what makes it a bounded grant: it vouches for this one name and can +# vouch for nothing else. The file name matches what `moshcode dns trust` +# writes, so the two agree instead of each leaving a copy the other ignores. +if [ "$TRUST_LOCAL" = "1" ] && [ "$DRY_RUN" = "0" ]; then + step "trusting $NAME on this machine" + + # Read back what is on disk rather than believing the variables above. This is + # the one step that installs a trust anchor, and a certificate that is not the + # bounded shape must not be installed merely because this run meant to write + # one. An older CA:TRUE certificate arriving here is precisely the case to + # refuse: trusted as an anchor, its key could vouch for any name at all. + if openssl x509 -in "$CRT" -noout -ext basicConstraints 2>/dev/null | grep -q 'CA:FALSE'; then + case "$(uname -s)" in + Darwin) + if security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain "$CRT" 2>/dev/null; then + say " ${DIM}trusted in the system keychain${OFF}" + else + warn "could not add $NAME to the system keychain" + fi ;; + *) + if have update-ca-certificates; then + if mkdir -p /usr/local/share/ca-certificates \ + && cp "$CRT" "/usr/local/share/ca-certificates/moshpit-$NAME.crt" \ + && update-ca-certificates >/dev/null 2>&1; then + say " ${DIM}trusted in the system store — curl https://$NAME verifies here now${OFF}" + else + warn "could not install $NAME into the system trust store" + fi + else + warn "no update-ca-certificates here — skipping local trust for $NAME" + fi ;; + esac + else + warn "$CRT is not CA:FALSE, so it was not trusted on this machine." + warn "a certificate trusted as a CA can vouch for any name, not just $NAME." + warn "re-run this script to re-issue it — the key is reused, so the pin does not change." + fi +fi + # ------------------------------------------------------------------ the pin step "the pin to publish" diff --git a/tests/setup-origin.test.ts b/tests/setup-origin.test.ts new file mode 100644 index 0000000..baf1bff --- /dev/null +++ b/tests/setup-origin.test.ts @@ -0,0 +1,176 @@ +// What `setup-origin.sh` issues, and what it refuses to do. +// +// The script needs root and a running nginx to do its real job, so what is +// exercised here is the part that has to be right regardless: the shape of the +// certificate it mints, and the argument handling that runs before anything is +// written. Both are reachable without privileges — the validation runs before +// the root check, and the certificate flags are read out of the script itself +// and handed to the same openssl the script would call. + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { opensslPin, tempDir } from "./helpers.ts"; + +const run = promisify(execFile); +const script = fileURLToPath(new URL("../scripts/setup-origin.sh", import.meta.url)); + +/** Run the script, returning its exit code and stderr rather than throwing. */ +async function sh(args: string[], env: NodeJS.ProcessEnv = {}) { + try { + const { stdout, stderr } = await run("sh", [script, ...args], { env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr }; + } catch (err: any) { + return { code: err.code ?? 1, stdout: String(err.stdout || ""), stderr: String(err.stderr || "") }; + } +} + +/** + * The certificate extensions the script passes to openssl, read out of the + * script rather than restated here. + * + * Restating them would produce a test that passes while the script mints + * something else entirely — which is exactly the failure this file exists to + * catch, since a CA:TRUE certificate is indistinguishable from a correct one + * until someone trusts it. + */ +async function leafExtensions(): Promise { + const body = await readFile(script, "utf8"); + const flags = ["LEAF_EXT", "LEAF_USE", "LEAF_EKU"].map((name) => { + const found = new RegExp(`^${name}='([^']+)'`, "m").exec(body); + assert.ok(found, `${name} is no longer set in setup-origin.sh`); + return found[1]; + }); + return flags.flatMap((ext) => ["-addext", ext]); +} + +describe("setup-origin.sh — what it issues", () => { + test("the certificate is not a CA, and says so critically", async () => { + const dir = await tempDir(); + const crt = join(dir, "cert.pem"); + const key = join(dir, "key.pem"); + + await run("openssl", [ + "req", "-x509", "-new", "-nodes", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-sha256", "-days", "30", + "-subj", "/CN=chovy.hacker", "-addext", "subjectAltName=DNS:chovy.hacker", + ...(await leafExtensions()), + "-keyout", key, "-out", crt, + ]); + + const { stdout } = await run("openssl", ["x509", "-in", crt, "-noout", "-text"]); + + // The property the whole thing turns on. This certificate is trusted + // directly — it is its own anchor — and an anchor marked CA:TRUE may issue + // for any name at all. The SAN bounds what it speaks for; it does not bound + // what a key trusted as a CA can go on to sign. + assert.match(stdout, /X509v3 Basic Constraints: critical\s*\n\s*CA:FALSE/); + assert.doesNotMatch(stdout, /CA:TRUE/); + + // One name, so trusting it is a grant over one name. + const sans = /X509v3 Subject Alternative Name:\s*\n\s*(.+)/.exec(stdout)?.[1] ?? ""; + assert.equal(sans.trim(), "DNS:chovy.hacker"); + assert.match(stdout, /TLS Web Server Authentication/); + }); + + test("it verifies as its own anchor, which is how a stock client accepts it", async () => { + const dir = await tempDir(); + const crt = join(dir, "cert.pem"); + const key = join(dir, "key.pem"); + + await run("openssl", [ + "req", "-x509", "-new", "-nodes", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-sha256", "-days", "30", + "-subj", "/CN=seo.rank", "-addext", "subjectAltName=DNS:seo.rank", + ...(await leafExtensions()), + "-keyout", key, "-out", crt, + ]); + + // CA:FALSE and "usable as a trust anchor" sound contradictory and are not: + // a certificate found in the trust store is trusted as itself, and the + // basicConstraints CA bit only governs whether it may certify *others*. + // If this ever stopped holding, the local trust step would install a file + // that changes nothing and report success. + const { stdout } = await run("openssl", ["verify", "-CAfile", crt, crt]); + assert.match(stdout, /OK/); + }); + + test("re-issuing from the same key does not move the pin", async () => { + const dir = await tempDir(); + const key = join(dir, "key.pem"); + const first = join(dir, "first.pem"); + const second = join(dir, "second.pem"); + const ext = await leafExtensions(); + + await run("openssl", [ + "req", "-x509", "-new", "-nodes", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-sha256", "-days", "30", + "-subj", "/CN=alt.2600", "-addext", "subjectAltName=DNS:alt.2600", + ...ext, "-keyout", key, "-out", first, + ]); + await run("openssl", [ + "req", "-x509", "-new", "-nodes", "-key", key, + "-sha256", "-days", "60", + "-subj", "/CN=alt.2600", "-addext", "subjectAltName=DNS:alt.2600", + ...ext, "-out", second, + ]); + + // This is what makes repairing an already-published CA:TRUE certificate + // free: the pin is over the key, so re-issuing needs no registry change and + // no flag day. Without it, fixing the certificate would break every client + // holding the old pin. + assert.equal(await opensslPin(second), await opensslPin(first)); + }); +}); + +describe("setup-origin.sh — what it refuses", () => { + test("a name that would escape the directories it writes to", async () => { + // The name becomes a path under /etc/ssl, /etc/nginx and + // /usr/local/share/ca-certificates, and the script runs as root. + for (const bad of ["../../etc/evil.hacker", "a.b/../../x", "..hacker"]) { + const { code, stderr } = await sh([bad, "--dry-run"]); + assert.equal(code, 1, `${bad} was accepted`); + assert.match(stderr, /is not a hostname/); + } + }); + + test("a name with no dot in it", async () => { + const { code, stderr } = await sh(["hacker", "--dry-run"]); + assert.equal(code, 1); + assert.match(stderr, /does not look like a Moshpit name/); + }); + + test("--all given after the name, where it would be silently ignored", async () => { + const { code, stderr } = await sh(["good.hacker", "--all", "--dry-run"]); + assert.equal(code, 1); + assert.match(stderr, /--all goes first/); + }); + + test("--all with nothing to re-issue, rather than reporting success", async () => { + const dir = await tempDir(); + const { code, stderr } = await sh(["--all", "--dry-run"], { MOSHPIT_CERTDIR: dir }); + assert.equal(code, 1); + assert.match(stderr, /nothing to re-issue/); + }); +}); + +describe("setup-origin.sh — --all", () => { + test("re-issues every name the box already has a key for", async () => { + const dir = await tempDir(); + await run("sh", ["-c", `: > "${dir}/one.hacker.key"; : > "${dir}/two.rank.key"`]); + + const { code, stderr } = await sh(["--all", "--dry-run"], { MOSHPIT_CERTDIR: dir }); + assert.equal(code, 0); + // Named from the keys on disk, so repairing a fleet is one command and + // there is nothing to type and mistype. + assert.match(stderr, /==> one\.hacker/); + assert.match(stderr, /==> two\.rank/); + }); +});