Skip to content

Moshpit CA: the registry signs a certificate for every name it holds - #519

Merged
ralyodio merged 2 commits into
mainfrom
feat/moshpit-ca
Sep 16, 2026
Merged

ralyodio merged 2 commits into
mainfrom
feat/moshpit-ca

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

The registry becomes a certificate authority for the names it holds. A client that installs the Moshpit root once trusts every pit name over https, instead of learning each name against its pin. TronBrowser will bundle the root and moshcode dns enable will install it (separate PRs); origins get 30-day leaves from the registry (setup-origin.sh change in moshpit-proxy, separate PR).

Why the registry, and why per name

No public CA issues for a name outside the ICANN root. The registry is the one party that already knows who holds a name, so it is the natural signer. Issuance uses controlledName(), the same rule pins use: the name's holder, or its tenant during a lease. That is what makes resale safe: whoever holds .foo cannot obtain a certificate for bar.foo once it is somebody else's, and ending owners never hold a signing key. Only the registry signs.

Design

  • Offline root (Moshpit Root CA, 20 years, pathlen 1) signs an online issuing intermediate (Moshpit Issuing CA, 10 years, pathlen 0). The service holds only the issuer: MOSHPIT_CA_CERT, MOSHPIT_CA_KEY, MOSHPIT_CA_ROOT, PEM or base64 of PEM. On load it checks the key matches the certificate and the issuer chains to the root, and refuses a half-configured CA. Unconfigured = enabled: false / 503, nothing else changes.
  • Leaves: CN=<name>, DNS:<name> + DNS:*.<name>, CA:FALSE, serverAuth, 30 days (MOSHPIT_CA_LEAF_DAYS), ECDSA P-256 signatures. Short lives instead of revocation.
  • The subject comes from the name, never the CSR. Real TLDs are refused unconditionally (isRealTld), so a registry bug cannot mint google.com. CSR signature must verify; RSA < 2048 refused. 24 issuances per name per day.
  • Every leaf's key is published as a tls pin (note moshpit-ca), so pin clients keep working through the switch.
  • @peculiar/x509 was already in the tree transitively via @simplewebauthn/server; it becomes a direct dependency (11-line lock diff).

Routes

GET  /api/moshpit/ca                     status, root subject + fingerprint, leaf days
GET  /api/moshpit/ca.crt                 the root, PEM (application/x-pem-file)
GET  /api/moshpit/ca-chain.crt           issuer + root
POST /api/moshpit/tlds/:tld/certs        { label, csr } → 201 { cert, chain, root, serial, not_after, pin }
GET  /api/moshpit/tlds/:tld/certs?label= issued list (public)
GET  /api/moshpit/certs/:serial          one certificate, PEM

Migration 024_moshpit_certs.sql records issuances (serial, pin, PEM, who, when). scripts/moshpit-ca-init.mjs makes the root and issuer once, offline, and writes the three service variables as base64 one-liners. apps/pwa/docs/moshpit-ca.md documents all of it.

Verified

  • test/moshpit-ca.test.mjs (16): the generated CA is a real chain under Node's X509Certificate; a leaf verifies against the issuer, names the host and *.host and nothing else, is not a CA, lasts 30 days, and its pin is published; the CSR's own subject is ignored; the ending owner is refused for a sold name and the buyer is not; during a lease the tenant may and the holder may not; real TLD refused; bad and tampered CSRs refused; unregistered name refused; issuance recorded; renewal loop stopped at 24; the root/chain material is right.
  • test/moshpit-ca-route.test.mjs (2): 503 and enabled: false with no CA; once configured, PEM root and chain with the right content type, a bearer key mints a leaf for its own name, another user's key gets 403, no key gets 401, the issued list and per-serial fetch work, /api/moshpit/pins returns the leaf's pin.
  • Full pwa suite: 767/767.

Production

The real root and issuer were generated with the init script. The root key and all material are in the logicsrc vault profullstack / moshpit-ca / prod. The three service variables are already set on the moshcode Railway service (--skip-deploys), so the CA is live the moment this merges and redeploys: GET https://pit.moshcode.sh/api/moshpit/ca will report enabled: true.

🤖 Generated with Claude Code

No public CA issues for a name outside the ICANN root, so https on a Moshpit name meant a self-signed leaf per origin and a client learning each name against its pin. The registry already decides who holds a name, so it now signs: an offline root, an online issuing intermediate (MOSHPIT_CA_CERT/KEY/ROOT, base64 PEM), and 30-day CA:FALSE leaves with DNS:<name> and *.<name>, issued to whoever controlledName() says controls the name, the holder or the current tenant. That is what keeps resale safe: the ending owner cannot obtain a certificate for a name they sold, and never holds a signing key. Real TLDs are refused unconditionally, the CSR never decides the subject, 24 issuances a day per name, and every leaf's key is published as a tls pin so pin clients keep working.

Routes: GET /api/moshpit/ca, ca.crt, ca-chain.crt; POST /api/moshpit/tlds/:tld/certs {label, csr}; GET .../certs?label= and /api/moshpit/certs/:serial. Migration 024 records issuances. scripts/moshpit-ca-init.mjs makes the root and issuer once, offline. @peculiar/x509 (already transitively present) becomes a direct dependency. Tests verify the chain with node's X509Certificate, the resale and lease rules, the refusals, and the HTTP shapes; full suite 767/767.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
if (leaf.error) return { ok: false, error: leaf.error, status: leaf.status || 400 };

await run(
`INSERT INTO moshpit_name_certs (${CERT_COLS}) VALUES (?,?,?,?,?,?,?,?,?)`,

/** One issued certificate by serial, with its PEM. */
export async function getNameCertificate(serial) {
return get(`SELECT ${CERT_COLS} FROM moshpit_name_certs WHERE serial = ?`, [String(serial || "")]);
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

42 finding(s) in the 13 file(s) this pull request changes.

MEDIUM: 42

Severity Rule Location
MEDIUM sql-template-interpolation apps/pwa/src/lib/moshpit-certs.mjs:44
MEDIUM sql-template-interpolation apps/pwa/src/lib/moshpit-certs.mjs:82
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:139
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:153
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:179
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:373
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:377
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:422
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:671
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:867
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:869
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:928
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:974
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1044
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1147
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1170
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1192
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1391
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1541
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1690
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1696
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1743
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1792
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1823
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1923
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2042
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2054
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2066
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2100
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2298
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2311
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2337
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2343
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2347
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2357
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2488
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2640
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2648
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2656
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2686
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2825
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:2978
57 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 8 | **MEDIUM**: 38 | **LOW**: 11

Not introduced by this pull request. The full set is in the Security tab.

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:86
HIGH sh-remote-script-execution install.sh:90
HIGH sh-remote-script-execution install.sh:258
HIGH sh-remote-script-execution install.sh:269
HIGH sh-remote-script-execution install.sh:275
HIGH tls-verification-disabled src/dns.mjs:766
MEDIUM sql-template-interpolation apps/pwa/src/routes/mcp.mjs:87
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:207
MEDIUM sql-string-concatenation src/cli-schema.mjs:161
MEDIUM sql-string-concatenation src/cli-schema.mjs:220
MEDIUM sql-string-concatenation src/cli-schema.mjs:592
MEDIUM sql-string-concatenation src/cli-schema.mjs:625
MEDIUM sql-string-concatenation src/cli-schema.mjs:732
MEDIUM sql-string-concatenation src/cli-schema.mjs:1476
MEDIUM sql-string-concatenation src/cli-schema.mjs:1494

…and 37 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

…--frozen-lockfile

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ralyodio
ralyodio merged commit e78d9d8 into main Sep 16, 2026
6 checks passed
ralyodio added a commit that referenced this pull request Sep 16, 2026
…nable and a new dns ca verb (#520)

The registry now signs a certificate for every name it holds (#519), so a machine that trusts its root once trusts every Moshpit name over https: curl, Firefox, Chromium, git. dns enable fetches /api/moshpit/ca and /api/moshpit/ca.crt, checks the served root is a self-signed CA:TRUE certificate whose fingerprint matches what the registry reports, keeps it at ~/.moshpit/ca/registry-root.crt, and installs it into the NSS store (no root) and the system store (as root) under the nickname Moshpit Root CA, the same nickname TronBrowser's launcher uses so neither imports the other's work twice. dns disable takes it out. moshcode dns ca does only that half, for a machine whose resolution comes from elsewhere; --remove undoes it. trustStores/untrustPlan take the nickname and system file as parameters, defaults unchanged. The root carries no name constraints, since X.509 cannot say everything except the ICANN root across seventeen thousand endings; the signer is the bound, and installing it stays an explicit act.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ralyodio added a commit to profullstack/tronbrowser.dev that referenced this pull request Sep 16, 2026
…the launcher on every start (#109)

The registry now signs a certificate for every name it holds (moshcoder/moshcode#519), so one root makes every Moshpit name trusted over https. install.sh ensure_moshpit_root fetches /api/moshpit/ca and /api/moshpit/ca.crt on install and on tron upgrade, refuses a root whose fingerprint does not match what the registry reports or that is not CA:TRUE, and keeps it next to the launcher as moshpit-root-ca.crt. The launcher's sync_moshpit_trust imports it into every browser trust store it writes under the nickname Moshpit Root CA, the one moshcode dns enable uses, so neither imports the other's work twice. certutil is now installed on machines that have the shipped root, not only ones with moshcode's certificates. Verified against the live registry: fetch, no-op on rerun, skip switch, a tampered answer refused, and the launcher importing it as C,,.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ralyodio added a commit to profullstack/moshpit-proxy that referenced this pull request Sep 16, 2026
…imer (#25)

The registry now runs a certificate authority for the names it holds (moshcoder/moshcode#519). With MOSHPIT_API_KEY set, setup-origin.sh sends a CSR for the name to POST /api/moshpit/tlds/:tld/certs and writes the chain it returns (leaf, issuer, root) over the certificate nginx serves, so any client that trusts the pit's root, TronBrowser or a box that ran moshcode dns enable, accepts the name with no pin lookup and no per-name import. Same key, so the published pin does not move and pin-checking clients keep working. The self-signed certificate stays as the fallback: no key, --self-signed, a registry without a CA, or a refusal all leave it in place and say so. With a signed chain the box trusts the pit's root instead of the leaf, under the same file moshcode uses. The 30-day leaf is renewed by scripts/moshpit-renew.sh, run daily by moshpit-renew.timer, which the script installs the first time it gets a signed certificate; the API key it needs is kept root-only at /etc/moshpit/renew.env.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@ralyodio
ralyodio deleted the feat/moshpit-ca branch September 17, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants