From 210dc4e8b61d6091c3ce3bb0c75d83aa675e0db3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 1 Aug 2026 17:54:43 +0000 Subject: [PATCH] Add the origin side: nginx template and a setup script that proves itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway config in this repo describes the box that routes without decrypting. Nothing described the box that actually terminates the session, which is where every live Moshpit name currently goes wrong. Diagnosed on a real one. `chovy.hacker` is registered with an IPv6 target pointing at a droplet, and direct-to-origin resolution works — no gateway involved. But the droplet answers :443 for that name with a Let's Encrypt certificate for `dev.profullstack.com`, because there is no server block for `chovy.hacker` and it falls through to the default vhost. Its :80 block then 301s to https://, so a browser is redirected straight into the error. The registry also has `pins: []`, so no client would accept the name even once the certificate is right. That is three separate mistakes and none of them is exotic. Hence a template and a script rather than prose. setup-origin.sh generates the key and self-signed certificate, writes the server block, reloads, and then connects back to 127.0.0.1:443 with the name as SNI to check what is actually presented. A clean `nginx -t` does not mean the name reaches the intended block, and the default-vhost fallthrough is precisely the bug this exists to prevent — so it is verified, not assumed. Reuses an existing key when one is present, because the pin is over the key: certificates can be regenerated freely without invalidating what is published. The template pins TLS 1.3 and asks for X25519MLKEM768, which needs nginx built against OpenSSL 3.5+. The script reads `nginx -V`, warns, and comments the line out rather than handing someone a failed reload that takes their live site down with it. Verified the pin the script prints is byte-identical to what lib/spki.ts computes — the value moshpit-proxy and TronBrowser actually check against. Shell syntax checked, dry-run exercised. 35 tests pass, tsc clean. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 33 +++++++ nginx/moshpit-origin.conf | 83 ++++++++++++++++++ scripts/setup-origin.sh | 177 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 nginx/moshpit-origin.conf create mode 100755 scripts/setup-origin.sh diff --git a/README.md b/README.md index 4843a1f..3615170 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,35 @@ Node, Python and Java keep their own trust stores — `NODE_EXTRA_CA_CERTS`, `certifi`, `cacerts` respectively. Installing into the OS store does not cover them. +## Serving a name (site operators) + +On the box that will host the name — a VPS, a droplet, anything with a public +address: + +```sh +sudo sh scripts/setup-origin.sh chovy.hacker +``` + +Generates a key and self-signed certificate, writes an nginx server block from +`nginx/moshpit-origin.conf`, reloads, connects back to itself to prove the name +now answers with the key it just made, and prints the pin to publish. `--dry-run` +shows what it would do. + +Self-signed is the design, not a shortcut: no CA will issue for a Moshpit TLD, so +identity comes from the registry rather than an issuer. Nothing about the +certificate is checked except the key, so an expensive one and this one are worth +exactly the same here. + +**The failure this is built to prevent:** a name with no matching server block +falls through to nginx's default vhost, which answers with a certificate for some +other name entirely. The browser reports an invalid certificate and the site looks +broken, when what is broken is one missing `server_name`. The script connects back +and checks, rather than trusting that a clean `nginx -t` means the name resolves +to the right block. + +Nothing is proxied on the server side. `moshpit-proxy` runs on the *visitor's* +machine — it is the thing that checks the pin on behalf of a browser that cannot. + ## Publishing a pin (site operators) ```sh @@ -103,6 +132,10 @@ npx moshpit-pin ./origin.crt # from a file npx moshpit-pin scrambled.eggs:443 # from what a live server presents ``` +`setup-origin.sh` prints this for you. Until the pin is published every client +refuses the name — there is no TOFU and no unauthenticated mode, because the pin +stands exactly where a certificate authority would. + Publish the result at [app.moshcode.sh/pit/dns](https://app.moshcode.sh/pit/dns). Keep the previous pin listed alongside the new one while rotating — the client accepts any pin in the list, so a key can change without a flag day. diff --git a/nginx/moshpit-origin.conf b/nginx/moshpit-origin.conf new file mode 100644 index 0000000..3c9c456 --- /dev/null +++ b/nginx/moshpit-origin.conf @@ -0,0 +1,83 @@ +# A Moshpit origin: serve one name over TLS with a key nobody vouched for. +# +# This is the other half of `moshpit-gateway.conf`. The gateway routes without +# decrypting; this is the box that actually terminates the session, and it is +# the only place in the whole path that holds a private key for the name. +# +# The certificate here is self-signed *on purpose*, and it is not a compromise. +# No CA will issue for a Moshpit TLD — the CA/Browser Forum banned non-IANA +# names in 2015 — so the trust does not come from an issuer. It comes from the +# registry, which publishes SHA-256(SubjectPublicKeyInfo) for this name and lets +# every client check the key it was handed against it. An expensive certificate +# and this one are worth exactly the same here, so this one is correct. +# +# Replace NAME below, or let `scripts/setup-origin.sh` do it and publish the pin. + +server { + listen 80; + listen [::]:80; + server_name NAME; + + # Keep this. Upgrading to HTTPS is right, and it was only ever a problem + # when there was no 443 block for the name to land on — which produced a + # certificate for whatever the default vhost happened to hold, and an error + # that looks like Moshpit is broken when it is nginx that is misconfigured. + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name NAME; + + # A name with no matching server block falls through to the default vhost, + # whose certificate is for some other name entirely. That is a name mismatch + # in the browser and it is the single most common way an origin looks broken. + # If this is the only site on the box, add `default_server` to the listens. + + ssl_certificate /etc/ssl/moshpit/NAME.crt; + ssl_certificate_key /etc/ssl/moshpit/NAME.key; + + # 1.3 only. There is no legacy client to support here — every Moshpit client + # is either TronBrowser, moshpit-proxy or a native SDK, all of them current — + # and 1.3 is what carries the hybrid key exchange below. + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; + + # Post-quantum key agreement, hybrid with X25519 so that breaking either one + # alone is not enough. Needs nginx built against OpenSSL 3.5+; check with + # `nginx -V 2>&1 | grep -o 'OpenSSL [0-9.]*'`. On an older build nginx will + # fail to start with "SSL_CONF_cmd Groups" — delete this line and the site + # still works, just without the post-quantum half. moshpit-proxy will log + # `origin has no ML-KEM` when that happens, so it is visible rather than silent. + ssl_conf_command Groups X25519MLKEM768:X25519:P-256; + + # Session tickets off: they are a second, weaker way to resume that does not + # go through the pin check. + ssl_session_tickets off; + ssl_session_cache shared:MoshpitSSL:10m; + + # HSTS is deliberately absent. It is a promise to a browser about a name the + # public DNS does not own, and a stale HSTS entry for a Moshpit name is not + # something a user can easily clear. + + access_log /var/log/nginx/NAME.access.log; + error_log /var/log/nginx/NAME.error.log warn; + + root /var/www/NAME; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + # Serving an app instead of files? Replace the block above with: + # + # location / { + # proxy_pass http://127.0.0.1:8080; + # proxy_set_header Host $host; + # proxy_set_header X-Forwarded-Proto https; + # proxy_http_version 1.1; + # } +} diff --git a/scripts/setup-origin.sh b/scripts/setup-origin.sh new file mode 100755 index 0000000..6271959 --- /dev/null +++ b/scripts/setup-origin.sh @@ -0,0 +1,177 @@ +#!/bin/sh +# Put a Moshpit name on this box: key, certificate, nginx block, and the pin. +# +# sudo sh scripts/setup-origin.sh chovy.hacker +# +# 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. +# +# 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) +# for the name and clients checking the key they were handed against it. Which +# means the last step is not optional: until the pin is published, every client +# refuses the name rather than trusting it on sight. +set -eu + +NAME="${1:-}" +CERTDIR="${MOSHPIT_CERTDIR:-/etc/ssl/moshpit}" +SITEDIR="${MOSHPIT_SITEDIR:-/etc/nginx/sites-available}" +ENABLEDIR="${MOSHPIT_ENABLEDIR:-/etc/nginx/sites-enabled}" +WEBROOT="${MOSHPIT_WEBROOT:-/var/www/$NAME}" +DAYS="${MOSHPIT_DAYS:-825}" +TEMPLATE="${MOSHPIT_TEMPLATE:-$(dirname "$0")/../nginx/moshpit-origin.conf}" +DRY_RUN=0 + +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 +say() { printf '%s\n' "$*" >&2; } +step() { printf '%s==>%s %s\n' "$BOLD" "$OFF" "$*" >&2; } +warn() { printf '%swarning:%s %s\n' "$RED" "$OFF" "$*" >&2; } +die() { printf '%serror:%s %s\n' "$RED" "$OFF" "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +shift 2>/dev/null || true +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --days) DAYS="${2:?--days needs a number}"; shift ;; + --webroot) WEBROOT="${2:?--webroot needs a path}"; shift ;; + -h|--help) + cat >&2 < [options] + + --dry-run write nothing, print what would happen + --days certificate lifetime (default: $DAYS) + --webroot site files (default: /var/www/) + +environment: MOSHPIT_CERTDIR, MOSHPIT_SITEDIR, MOSHPIT_ENABLEDIR, MOSHPIT_WEBROOT +EOF + exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac + shift +done + +[ -n "$NAME" ] || die "usage: setup-origin.sh (e.g. chovy.hacker)" +case "$NAME" in + *.*) ;; + *) die "'$NAME' does not look like a Moshpit name" ;; +esac +have openssl || die "openssl is required" +[ -f "$TEMPLATE" ] || die "template not found: $TEMPLATE" + +if [ "$DRY_RUN" = "0" ] && [ "$(id -u)" != "0" ]; then + die "needs root to write $CERTDIR and reload nginx (try: sudo sh $0 $NAME)" +fi + +# ------------------------------------------------------------------ warn early + +# nginx built against OpenSSL below 3.5 has no ML-KEM, and the Groups line in +# the template will stop it from starting. Better to say so now than to hand +# someone a failed reload and a live site that went down with it. +if have nginx; then + ssl_ver=$(nginx -V 2>&1 | grep -o 'OpenSSL [0-9][0-9.]*' | head -1 | cut -d' ' -f2 || true) + case "$ssl_ver" in + 3.5*|3.6*|3.7*|3.8*|3.9*|4.*) ;; + "") warn "could not read nginx's OpenSSL version; if the reload fails, remove the ssl_conf_command line" ;; + *) warn "nginx is built against OpenSSL $ssl_ver — no ML-KEM below 3.5." + warn "the post-quantum line will be commented out; the site still works." ;; + esac +fi + +# ------------------------------------------------------------------ key + cert + +step "generating a key and certificate for $NAME" +if [ "$DRY_RUN" = "0" ]; then + mkdir -p "$CERTDIR" + chmod 700 "$CERTDIR" +fi + +CRT="$CERTDIR/$NAME.crt" +KEY="$CERTDIR/$NAME.key" + +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. + 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" + 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" \ + -keyout "$KEY" -out "$CRT" + chmod 600 "$KEY" + fi +fi + +# ------------------------------------------------------------------ nginx + +step "writing the nginx server block" +CONF="$SITEDIR/$NAME" +if [ "$DRY_RUN" = "0" ]; then + mkdir -p "$SITEDIR" "$ENABLEDIR" "$WEBROOT" + sed -e "s|NAME|$NAME|g" -e "s|/var/www/$NAME|$WEBROOT|g" "$TEMPLATE" > "$CONF" + + case "$ssl_ver" in + 3.5*|3.6*|3.7*|3.8*|3.9*|4.*|"") ;; + *) sed -i 's|^\( *\)ssl_conf_command Groups|\1# ssl_conf_command Groups|' "$CONF" ;; + esac + + [ -e "$ENABLEDIR/$NAME" ] || ln -s "$CONF" "$ENABLEDIR/$NAME" + [ -f "$WEBROOT/index.html" ] || printf '%s

%s

served over Moshpit.\n' "$NAME" "$NAME" > "$WEBROOT/index.html" + + nginx -t || die "nginx rejected the config — nothing was reloaded, the old site is still up" + nginx -s reload || die "nginx reload failed" +fi + +# ------------------------------------------------------------------ prove it + +# Not ceremony. nginx will happily reload with a block that never matches, and +# the failure mode is the default vhost answering with someone else's +# certificate — which is exactly the bug this script exists to fix. So ask the +# running server what it actually presents for this name. +if [ "$DRY_RUN" = "0" ]; then + step "checking what the server now presents for $NAME" + presented=$(echo | openssl s_client -connect 127.0.0.1:443 -servername "$NAME" 2>/dev/null \ + | openssl x509 -noout -subject 2>/dev/null || true) + case "$presented" in + *"$NAME"*) say " ${DIM}$presented${OFF}" ;; + "") warn "could not read a certificate back from 127.0.0.1:443" ;; + *) warn "the server answered '$NAME' with: $presented" + warn "another server block is matching first — check for a default_server" ;; + esac +fi + +# ------------------------------------------------------------------ the pin + +step "the pin to publish" +if [ "$DRY_RUN" = "0" ]; then + PIN=$(openssl x509 -in "$CRT" -pubkey -noout \ + | openssl pkey -pubin -outform der \ + | openssl dgst -sha256 -binary \ + | openssl base64 -A) +else + PIN="(dry run — no key was generated)" +fi + +cat >&2 <