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
102 changes: 102 additions & 0 deletions apps/pwa/docs/moshpit-ca.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# The Moshpit certificate authority

No public CA will issue for a name outside the ICANN root, so `https://` on a
Moshpit name used to mean a self-signed leaf per origin and a client that
learned each name one at a time against the pin the registry publishes. The
registry is the one party that already knows who holds a name, which is what a
CA needs to know before it signs. So it signs.

Install the root once and every pit name is trusted: curl, Firefox, Chromium,
git, all of it. TronBrowser ships the root; `moshcode dns enable` installs it.

## Shape

| | who holds the key | signs | lives |
| --- | --- | --- | --- |
| root, `Moshpit Root CA` | nobody online: the vault | the issuer, once | 20 years |
| issuer, `Moshpit Issuing CA` | the registry service (`MOSHPIT_CA_KEY`) | leaves | 10 years |
| leaf, one per name | the origin | nothing (`CA:FALSE`) | 30 days, renewed by the origin |

Leaves carry `DNS:<name>` and `DNS:*.<name>`, `serverAuth`, and nothing else.
Short lives are what make revocation unnecessary: a compromised key is out of
the world within a month and is not renewed, and there is no CRL or OCSP to run
or to fail closed on.

## Who may ask

The rule pins use, `controlledName()`: the name's holder, or its tenant while
a lease runs. That is what makes resale safe. Whoever holds `.foo` cannot
obtain a certificate for `bar.foo` once it is somebody else's, and a tenant of
`blue.eggs` gets one while the holder does not. Ending owners never hold a
signing key; only the registry signs.

The subject is the name, never the CSR's. A request for `blue.eggs` cannot come
out naming `red.eggs` however the CSR is written. Names whose ending is a real
top-level domain are refused unconditionally, whatever the registry's tables
say, so a registry bug cannot mint `google.com`. Twenty-four issuances per name
per day, so a broken renewal loop is stopped rather than served.

Every leaf's key is also published as a `tls` pin, so a client that still
checks pins keeps working through the transition.

## API

```
GET /api/moshpit/ca { enabled, root: { subject, fingerprint_sha256, url }, leaf_days }
GET /api/moshpit/ca.crt the root, PEM: what a client installs
GET /api/moshpit/ca-chain.crt issuer then root, PEM: what an origin serves after its leaf
POST /api/moshpit/tlds/:tld/certs { label, csr } → 201 { name, serial, cert, chain, root, not_after, pin }
GET /api/moshpit/tlds/:tld/certs?label= what has been issued under a name
GET /api/moshpit/certs/:serial one issued certificate, PEM
```

`POST` takes the same bearer API key as every other `/api/moshpit` write. With
no CA configured the read endpoints answer `enabled: false` or 503 and nothing
else in the registry changes.

An origin, in shell:

```sh
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout blue.eggs.key -subj /CN=blue.eggs -out blue.eggs.csr
curl -sS -X POST https://pit.moshcode.sh/api/moshpit/tlds/eggs/certs \
-H "authorization: Bearer $MOSHPIT_API_KEY" -H "content-type: application/json" \
-d "$(jq -n --arg label blue --rawfile csr blue.eggs.csr '{label:$label, csr:$csr}')" \
| jq -r .chain > blue.eggs.fullchain.crt
```

Point the server at `fullchain.crt` and the key, and renew on a timer before
day 30. `moshpit-proxy/scripts/setup-origin.sh` does all of this.

## Setting it up, once

```sh
node scripts/moshpit-ca-init.mjs --out ~/.moshpit-ca
```

writes `root.key`, `root.crt`, `issuing.key`, `issuing.crt` and `railway.env`.
The root key goes to the vault and nowhere else. The three lines in
`railway.env` are the service variables: `MOSHPIT_CA_CERT`, `MOSHPIT_CA_KEY`,
`MOSHPIT_CA_ROOT`, base64 of the PEM so they survive as single-line values.
`MOSHPIT_CA_LEAF_DAYS` (default 30) is optional. On start the service checks
that the key matches the certificate and that the issuer chains to the root,
and refuses to come up with the CA half-configured rather than sign wrongly.

Rotating the issuer is: sign a new issuing pair with the root (offline), set
the variables, restart. Leaves already out keep working until they renew,
because the chain they serve includes the issuer that signed them.

## Where clients get the root

- TronBrowser: bundled in the release, imported into the browser's trust
store by the launcher on every start.
- `moshcode dns enable`: installed into the system store, replacing the
per-machine local CA it used to generate for its proxy.
- Anything else: `curl https://pit.moshcode.sh/api/moshpit/ca.crt` and
install it the way any private CA is installed. Phones take it through a
profile (iOS) or Settings (Android); most Android apps ignore user-added
roots by design, so browsers there work and third-party apps do not.

What no root program can give us: stock Chrome on a stranger's machine will
never trust `.hacker`, because public root programs only admit CAs for names
under the ICANN root. That limit belongs to the namespace, not to this CA.
11 changes: 11 additions & 0 deletions apps/pwa/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"dependencies": {
"@libsql/client": "^0.14.0",
"@peculiar/x509": "^1.14.3",
"@profullstack/synconfig": "^0.1.1",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.1.0",
Expand Down
3 changes: 3 additions & 0 deletions apps/pwa/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions apps/pwa/scripts/moshpit-ca-init.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env node
// Create the Moshpit certificate authority: a root, and the issuing
// intermediate the registry signs with. Run once, offline, by a person.
//
// node scripts/moshpit-ca-init.mjs --out ~/.moshpit-ca
//
// Writes, mode 0600, into --out (which must not already hold a root.key):
// root.key keep cold: the vault, never a server. Signs issuers only.
// root.crt what every client installs. Public. Shipped with TronBrowser
// and installed by `moshcode dns enable`.
// issuing.key the registry's signing key: MOSHPIT_CA_KEY on the service.
// issuing.crt its certificate: MOSHPIT_CA_CERT
// root.crt also the service's MOSHPIT_CA_ROOT
// railway.env the three service variables, base64 one-liners, ready to paste
//
// Nothing here talks to the network and nothing prints a private key.
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { generateCa } from "../src/lib/moshpit-ca.mjs";

const args = process.argv.slice(2);
const flag = (name, fallback) => {
const i = args.indexOf(name);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
};
const out = path.resolve(flag("--out", "moshpit-ca"));
const rootName = flag("--root-name", "Moshpit Root CA");
const issuerName = flag("--issuer-name", "Moshpit Issuing CA");

if (existsSync(path.join(out, "root.key"))) {
console.error(`${out}/root.key already exists — refusing to overwrite a root. Use another --out.`);
process.exit(1);
}
mkdirSync(out, { recursive: true, mode: 0o700 });

const ca = await generateCa({ rootName, issuerName });
const put = (name, body) => writeFileSync(path.join(out, name), body, { mode: 0o600 });
put("root.key", ca.root.key);
put("root.crt", ca.root.cert);
put("issuing.key", ca.issuer.key);
put("issuing.crt", ca.issuer.cert);
const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
put("railway.env", [
`MOSHPIT_CA_CERT=${b64(ca.issuer.cert)}`,
`MOSHPIT_CA_KEY=${b64(ca.issuer.key)}`,
`MOSHPIT_CA_ROOT=${b64(ca.root.cert)}`,
"",
].join("\n"));

console.log(`Moshpit CA written to ${out}/`);
console.log(` root.key -> the vault. Never a server, never an environment variable.`);
console.log(` root.crt -> public; TronBrowser bundles it, moshcode dns enable installs it.`);
console.log(` issuing.* -> the registry service: MOSHPIT_CA_CERT / MOSHPIT_CA_KEY / MOSHPIT_CA_ROOT`);
console.log(` railway.env -> the three variables as single-line base64, paste into the service.`);
11 changes: 11 additions & 0 deletions apps/pwa/src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ export const config = {
subject: process.env.VAPID_SUBJECT || "mailto:anthony@profullstack.com",
},
telegram: { botToken: process.env.TELEGRAM_BOT_TOKEN || "" },
// The Moshpit certificate authority (src/lib/moshpit-ca.mjs). PEM, or base64
// of PEM (Railway variables are single-line). All three absent = the CA is off
// and its endpoints answer 503; the rest of the registry is unaffected. The
// root's private key is never configured anywhere: it signs the issuer once,
// offline, and stays in the vault.
moshpitCa: {
cert: process.env.MOSHPIT_CA_CERT || "",
key: process.env.MOSHPIT_CA_KEY || "",
root: process.env.MOSHPIT_CA_ROOT || "",
leafDays: Number(process.env.MOSHPIT_CA_LEAF_DAYS) || 30,
},
slack: { defaultWebhook: process.env.SLACK_WEBHOOK_URL || "" },
coinpay: {
apiBase: (process.env.COINPAY_API_BASE || "https://coinpayportal.com").replace(/\/+$/, ""),
Expand Down
Loading
Loading