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 <