Registry-pinned TLS for Moshpit names. The origin's key is checked against what
the registry published for that name — not against a certificate authority,
because no certificate authority will ever vouch for .moshpit.
You can't, and it isn't a matter of persuasion. The CA/Browser Forum Baseline
Requirements banned certificates for non-IANA names: issuance stopped in
November 2015 and existing ones were revoked by October 2016. A CA in a browser
root store that issued for .moshpit would be distrusted for doing it. The rule
is the penalty.
So the trust has to come from somewhere else. It comes from the registry, which already knows who owns each name — a stronger claim than domain validation ever made, since it proves registry ownership rather than momentary control of a socket.
| Gateway sees | ||
|---|---|---|
| A. Gateway terminates TLS | everything | simplest, but the gateway is a designed-in MITM |
| B. Gateway SNI-passthrough | hostname only | implemented here |
| C. Registry returns origin IP | nothing | leaks origin IPs, breaks NAT'd sites |
A central terminating gateway and end-to-end encryption are mutually exclusive. This is topology B: nginx routes on the SNI without a private key, so the session that terminates at the origin is genuinely end-to-end.
tronbrowser.dev has built-in Moshpit support. It resolves names and verifies registry pins natively, so it needs no DNS configuration, no local proxy, and no root certificate. Nothing in this package is required to browse Moshpit from TronBrowser.
Everything below exists for stock browsers, which cannot be taught to check a registry pin — there is no certificate-validation API in any browser extension model.
browser --TLS--> proxy --TLS--> gateway (ssl_preread) --TCP--> origin
local CA registry-pinned key
The right-hand session is the real one. The left-hand session exists only to say the result in the one language a browser accepts.
npm start # or: node bin/moshpit-proxy.tsPair it with moshpit-dns, which points .moshpit names at
loopback.
| Variable | Default | |
|---|---|---|
MOSHPIT_PROXY_PORT |
8443 |
443 needs privileges; the installer moves it |
MOSHPIT_PROXY_HOST |
127.0.0.1 |
|
MOSHPIT_GATEWAY_HOST |
pit.moshcode.sh |
|
MOSHPIT_REGISTRY_BASE |
https://pit.moshcode.sh |
|
MOSHPIT_PROXY_TLDS |
moshpit |
comma-separated |
MOSHPIT_PROXY_PINS |
~/.moshpit/pins.json |
overrides, win over the registry |
MOSHPIT_PROXY_TOFU |
off | see below |
MOSHPIT_PROXY_DIR |
~/.moshpit |
curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | shInstalls under ~/.local, needs no root for the code, and finishes by setting up
your browsers — asking once, in plain language, before it changes anything:
Moshpit needs to add a security key to this
computer so your browser trusts .moshpit sites.
It only works for .moshpit and cannot affect any
other website.
Continue? [Y/n]
--no-trust installs the code only. --uninstall removes both.
Already have the code? moshpit-trust does the browser setup on its own, and
moshpit-trust --status says what is set up without changing anything. It is
idempotent — running it twice is a no-op, not a duplicate.
Nothing in this section applies to TronBrowser, which verifies registry pins natively. No setup, no proxy, no local root.
moshpit-trust automates everything below; this section is what it does and why,
for anyone who would rather do it by hand or wants to know what changed.
The proxy generates a root on first run at ~/.moshpit/ca/ca.crt and prints its
fingerprint. Two things make installing it a much smaller ask than a shared CA:
- The key never leaves this machine. Compromising Moshpit's infrastructure does not forge certificates for you.
- It carries a critical
nameConstraintsextension permitting only the Moshpit TLDs and excluding every other name form. Even holding the key, the worst reachable outcome is forging a name you already control.
That second property is tested, not asserted — tests/ca.test.ts signs a
www.google.com certificate with the root and checks that openssl rejects it
with permitted subtree violation.
# Linux (NSS: Chrome, Chromium, Edge)
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "Moshpit Local CA" -i ~/.moshpit/ca/ca.crt
# macOS
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.moshpit/ca/ca.crtC,, is server-trust only — not code signing, not mail.
Firefox is not covered by either command. It carries its own NSS database per profile on every platform, including macOS, so the keychain does nothing for it:
certutil -d sql:~/.mozilla/firefox/<profile> -A -t "C,," -n "Moshpit Local CA" -i ~/.moshpit/ca/ca.crtOn current Ubuntu the default Firefox is the snap, whose profiles live under
~/snap/firefox/common/.mozilla/firefox/ instead. moshpit-trust covers the
snap and flatpak paths as well, which is most of why doing this by hand tends to
half-work.
certutil itself is not installed by default on Debian or Ubuntu — the command
above fails on a machine that does have the store it points at. Install
libnss3-tools (Debian/Ubuntu), nss-tools (Fedora/RHEL) or nss (Arch, brew).
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, and neither does moshpit-trust.
On the box that will host the name — a VPS, a droplet, anything with a public address:
sudo sh scripts/setup-origin.sh chovy.hackerGenerates 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.
setup-origin.sh stops at printing the pin because publishing it needs an
account. With an API key it does not stop:
MOSHPIT_API_KEY=... sh scripts/setup-origin.sh chovy.hacker \
--target dev.profullstack.comKey, certificate, nginx block, reload, verify, publish the pin, set the target, and read it back from the registry — the whole of "serve this name". Get a key at app.moshcode.sh/settings.
This works because /api/moshpit accepts a bearer token as well as a cookie
session, so nothing here needs a browser. Without a key the script behaves
exactly as before and prints the pin for you to paste.
The read-back at the end is the point. A 201 means the write was accepted;
only a read proves the endpoint clients actually query returns it, and a pin
that was accepted but is not served leaves the name refused with nothing to
show for it.
--target will not take an IPv4 literal, because the registry refuses one
by design — an A record on a small host is leased, NATed or shared, and a name
pointed at one goes stale silently. Use a hostname: the address behind it is
resolved normally, which is also how a name reaches IPv4-only clients.
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.
npx moshpit-pin ./origin.crt # from a file
npx moshpit-pin scrambled.eggs:443 # from what a live server presentssetup-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 — sign in, open the Yours tab, and use Key pins on the TLD.
Not /pit/dns, which this file used to say. That page is the instructions for
pointing a machine's resolver at Moshpit — it has no pin form on it, so the
old link sent operators somewhere they could never finish the job.
The same page is where a name's points at target lives, which is the other half of making a name reachable.
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.
The pin is SHA-256(SubjectPublicKeyInfo), base64 — the RFC 7469 format, so
openssl x509 -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256
prints the same string. tests/spki.test.ts asserts we agree with openssl.
nginx/moshpit-gateway.conf — there is no ssl_certificate directive anywhere
in it, which is the point. Requires ngx_stream_module with ssl_preread
(already compiled into stock Debian/Ubuntu nginx).
node scripts/gen-upstreams.ts # writes the SNI->origin map, reloads nginxThe map is regenerated on a timer rather than looked up per connection, so the registry is not on the path of every TCP handshake.
- Key exchange —
X25519MLKEM768, free from OpenSSL 3.5+ and on by default in current Chrome and Firefox. This is the leg that matters, because harvest-now-decrypt-later only threatens confidentiality. - Authentication — a normal certificate chain is authenticated by RSA or ECDSA signatures, which Shor's algorithm breaks. A pin is authenticated by a hash; Grover's only halves preimage resistance, so SHA-256 still leaves ~128 bits. The pinning design is already post-quantum on the authentication side, with no ML-DSA and no chain bloat.
Because both ends of the trust decision are ours, server keys can move to ML-DSA whenever we like — no CA, no root program, no CA/B ballot. Browsers don't support PQ signatures in TLS yet, but the proxy terminates for the browser, so the proxy↔origin leg can run ML-DSA before the public web can.
The proxy configures no groups at all — the origin leg uses Node's defaults. On Node 24 against OpenSSL 3.5 those defaults already put the hybrid first and send a real ML-KEM key share in the first flight. Read off the wire from the ClientHello the proxy actually sends:
supported_groups: X25519MLKEM768, x25519, secp256r1, x448, secp384r1, ...
key_share sent : X25519MLKEM768(1216B), x25519(32B)
An origin on OpenSSL 3.0–3.4 — what Ubuntu 22.04 and 24.04 still ship — has no
ML-KEM, so the handshake silently falls back to x25519 and succeeds. The
session is fine against every adversary that exists today and decryptable by one
that doesn't yet. Nothing said so, which made the guarantee unobservable.
Every upstream leg is now classified and counted:
[proxy] ok scrambled.eggs (registry) TLSv1.3 hybrid-pq
[proxy] warn old.eggs: origin has no ML-KEM, fell back to classical/X25519
...
[proxy] 41 post-quantum, 3 classical
MOSHPIT_PROXY_REQUIRE_PQ=1 turns the fallback into a refusal. Leave it off
until the counters say the grid is ready — switching it on today takes every
pre-3.5 origin offline.
Node exposes no binding for SSL_get_negotiated_group(), so the group is read
through getEphemeralKeyInfo(), which cannot represent a hybrid KEM and returns
{} for one while naming any classical group. That is an inference from an
absence, so it is never trusted on faith: two loopback handshakes at startup
prove both halves of the mapping, the result is printed, and enforcement refuses
to engage if the proof fails. See lib/pq.ts.
The rest of the Moshpit stack is Bun. This is not, and the reason is specific rather than aesthetic. Bun 1.3.14 cannot do dynamic SNI:
tls.createServer({ SNICallback })— the callback is never invoked. Bun silently serves the default certificate instead, or fails the handshake if there isn't one.new TLSSocket(sock, { isServer: true, secureContext })— never completes the handshake, so peeking the ClientHello and wrapping the socket by hand doesn't work either.- A single wildcard certificate is not a way out:
*.moshpitis a wildcard directly beneath a TLD and every TLS stack rejects it, by the same rule that makes*.cominvalid. getPeerCertificate()returns a stub with noraw, so there is nothing to compute a pin from.
A proxy that mints a certificate per name needs all of the above. Node 24
supports every part of it and runs the TypeScript directly via type stripping,
so there is no build step. If Bun's node:tls catches up, this moves back.
- ALPN is forced to
http/1.1. Raw bytes are piped between two independent TLS sessions, so the protocol must match on both ends, and the origin's choice isn't known until after the browser's handshake has committed. Mirroring it properly means probing the origin fromSNICallbackand answeringALPNCallbackfrom cache. HTTP/3 would not survive a TCP proxy regardless. - iOS needs the profile installed and a second toggle under Settings → General → About → Certificate Trust Settings.
- Android 7+ does not trust user-installed CAs for app traffic by default. Browsers generally honour them; apps won't.
- TOFU is off by default.
MOSHPIT_PROXY_TOFU=1accepts the first key seen for a name and holds it against later substitution. It is a rollout aid for bringing up a grid before the registry serves pins — not a security model.
npm test # node --test tests/*.test.ts27 tests. The ones that matter are in tests/proxy.test.ts: they stand up a
real origin over real TLS and check that a vouched-for key gets through and a
substituted one is refused before any request byte leaves the machine.
Apache-2.0. See LICENSE and NOTICE.
Apache rather than MIT for a specific reason: this is protocol code meant to be reimplemented — TronBrowser performs the same pin verification independently — and Apache-2.0 carries an explicit patent grant where MIT is silent. Implement the pin scheme however you like; the NOTICE covers naming.
Security policy and the trust model behind the local CA: SECURITY.md.
systemd/moshpit-dns.service and systemd/00-moshpit.conf are the deployed
resolver setup, kept here because they were previously configured by hand on
each box and existed in no repository.
cp systemd/moshpit-dns.service /etc/systemd/system/
cp systemd/00-moshpit.conf /etc/systemd/resolved.conf.d/
systemctl daemon-reload && systemctl enable --now moshpit-dns
systemctl restart systemd-resolvedThe bridge runs as this machine's primary resolver, with no list of Moshpit endings anywhere. That is not a shortcut — a per-TLD list cannot be made to work:
- It does not scale.
moshcode dns install --writeemits every claimed ending as a routing domain — 5,661 of them on one line. systemd-resolved caps search domains near 1090 and drops the rest alphabetically, logging thousands ofFailed to add search domain '~zoology': Argument list too long. Endings past the cut are configured on disk and absent from the resolver. - Curating the list does not fix it either. A routing domain selects a
scope, and a scope tries its servers in order. With an upstream resolver in
the same scope,
.hackergoes there first, comes back NXDOMAIN, and systemd-resolved treats that as final — the bridge is never asked.
Sending every query to the bridge removes both problems. It answers Moshpit
names from the registry and forwards everything else upstream (mode=clearnet:
the ordinary internet owns any name it can answer; the registry is a backfill).
The drop-in is named 00- so it is read first: systemd-resolved appends DNS=
in filename order and uses the first server, rotating only on failure — never
on NXDOMAIN. So any pre-existing resolvers stay listed as a genuine fallback for
if the bridge stops, rather than shadowing it.
Do not set MOSHPIT_DNS_CATCHALL=1 here. It parks every unresolved name on
the registry's parking address, which on a machine's own resolver means
github.com resolves to a parking page. Correct startup logs say mode=clearnet.
If clearnet names start resolving to a 69.46.46.x address, check for a stray
moshcode dns start bound to 127.0.0.1:5354 — a loopback bind beats the
service's 0.0.0.0 bind and wins every local query.