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
125 changes: 120 additions & 5 deletions scripts/setup-origin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -46,8 +54,10 @@ have() { command -v "$1" >/dev/null 2>&1; }

usage() {
cat >&2 <<EOF
usage: setup-origin.sh <name> [options]
usage: setup-origin.sh <name|--all> [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 <n> certificate lifetime (default: $DAYS)
--webroot <dir> site files (default: /var/www/<name>)
Expand All @@ -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 ;;
Expand All @@ -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
Expand Down Expand Up @@ -134,20 +177,39 @@ 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
openssl req -x509 -new -nodes \
-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
Expand Down Expand Up @@ -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://<name>` 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"
Expand Down
176 changes: 176 additions & 0 deletions tests/setup-origin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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/);
});
});
Loading