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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,46 @@ 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
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.
Expand Down
83 changes: 83 additions & 0 deletions nginx/moshpit-origin.conf
Original file line number Diff line number Diff line change
@@ -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;
# }
}
177 changes: 177 additions & 0 deletions scripts/setup-origin.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
usage: setup-origin.sh <name> [options]

--dry-run write nothing, print what would happen
--days <n> certificate lifetime (default: $DAYS)
--webroot <dir> site files (default: /var/www/<name>)

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 <name> (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 '<!doctype html><meta charset=utf-8><title>%s</title><h1>%s</h1><p>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 <<EOF

${BOLD}$NAME${OFF}
$PIN

Publish it at https://app.moshcode.sh/pit/dns

Until you do, every client refuses this name — that is the design, not a
fault. There is no trust-on-first-use and no unauthenticated mode, because
the pin is the only thing standing where a certificate authority would be.

${DIM}Rotating later? Publish the new pin alongside the old one, switch the
server, then drop the old one. Clients accept any pin in the list.${OFF}
EOF
Loading