From f5481d8e9ebbe9f14cfd36fd5619118a336382d3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 16 Sep 2026 16:26:26 +0000 Subject: [PATCH 1/2] Moshpit CA: the registry signs a certificate for every name it holds 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: and *., 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 --- apps/pwa/docs/moshpit-ca.md | 102 +++++++ apps/pwa/package-lock.json | 11 + apps/pwa/package.json | 1 + apps/pwa/scripts/moshpit-ca-init.mjs | 54 ++++ apps/pwa/src/config.mjs | 11 + apps/pwa/src/lib/moshpit-ca.mjs | 276 ++++++++++++++++++ apps/pwa/src/lib/moshpit-certs.mjs | 83 ++++++ apps/pwa/src/migrations/024_moshpit_certs.sql | 27 ++ apps/pwa/src/moshpit.mjs | 4 +- apps/pwa/src/routes/moshpit.mjs | 98 +++++++ apps/pwa/test/moshpit-ca-route.test.mjs | 144 +++++++++ apps/pwa/test/moshpit-ca.test.mjs | 197 +++++++++++++ 12 files changed, 1006 insertions(+), 2 deletions(-) create mode 100644 apps/pwa/docs/moshpit-ca.md create mode 100644 apps/pwa/scripts/moshpit-ca-init.mjs create mode 100644 apps/pwa/src/lib/moshpit-ca.mjs create mode 100644 apps/pwa/src/lib/moshpit-certs.mjs create mode 100644 apps/pwa/src/migrations/024_moshpit_certs.sql create mode 100644 apps/pwa/test/moshpit-ca-route.test.mjs create mode 100644 apps/pwa/test/moshpit-ca.test.mjs diff --git a/apps/pwa/docs/moshpit-ca.md b/apps/pwa/docs/moshpit-ca.md new file mode 100644 index 00000000..abea9231 --- /dev/null +++ b/apps/pwa/docs/moshpit-ca.md @@ -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:` and `DNS:*.`, `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. diff --git a/apps/pwa/package-lock.json b/apps/pwa/package-lock.json index ad7aed07..94aa1f19 100644 --- a/apps/pwa/package-lock.json +++ b/apps/pwa/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "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", "@xterm/addon-fit": "^0.11.0", @@ -367,6 +369,15 @@ "node": ">=20.0.0" } }, + "node_modules/@profullstack/synconfig": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@profullstack/synconfig/-/synconfig-0.1.3.tgz", + "integrity": "sha512-Ty/90ibgu2DVlablDeoFdItcobK+c8cMQFvuTfJOUlCfyvQZWoPjgt0JHPwTvq+ynOQ4i1ivBQ3K3PY/S4rObA==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@simplewebauthn/browser": { "version": "13.3.0", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", diff --git a/apps/pwa/package.json b/apps/pwa/package.json index f4a64932..f7eedad8 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -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", diff --git a/apps/pwa/scripts/moshpit-ca-init.mjs b/apps/pwa/scripts/moshpit-ca-init.mjs new file mode 100644 index 00000000..482ce8c8 --- /dev/null +++ b/apps/pwa/scripts/moshpit-ca-init.mjs @@ -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.`); diff --git a/apps/pwa/src/config.mjs b/apps/pwa/src/config.mjs index d7c8e28c..c3d22c5d 100644 --- a/apps/pwa/src/config.mjs +++ b/apps/pwa/src/config.mjs @@ -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(/\/+$/, ""), diff --git a/apps/pwa/src/lib/moshpit-ca.mjs b/apps/pwa/src/lib/moshpit-ca.mjs new file mode 100644 index 00000000..43f3a03c --- /dev/null +++ b/apps/pwa/src/lib/moshpit-ca.mjs @@ -0,0 +1,276 @@ +// The Moshpit certificate authority. +// +// No public CA will ever issue for a name outside the ICANN root, so until now +// every Moshpit origin served a self-signed leaf and every client had to learn +// 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 exactly what a +// CA needs to know before it signs -- so it signs. A client that trusts the +// root once trusts every pit name, and the per-name imports go away. +// +// Shape: +// root offline; its key never touches this server. Signs the issuer. +// issuer the online intermediate: MOSHPIT_CA_CERT + MOSHPIT_CA_KEY. +// leaf one per name, short-lived (30 days by default), CA:FALSE, +// SAN = the name and everything under it, renewed by the origin. +// +// Who may ask for a leaf is decided in moshpit-certs.mjs with the same rule +// pins use: the name's holder, or its tenant during a lease. This module only +// knows how to sign. +// +// Two refusals live here because they are properties of the certificate, not +// of the caller: a name whose ending is a real top-level domain is never +// signed (a registry bug must not be able to mint google.com), and the CSR's +// signature must verify (the requester holds the key they want certified). +import { webcrypto, createHash, randomBytes } from "node:crypto"; +import * as x509 from "@peculiar/x509"; +import { config } from "../config.mjs"; +import { isRealTld } from "./iana-tlds.mjs"; + +x509.cryptoProvider.set(webcrypto); + +export const CA_ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }; +const DAY = 24 * 60 * 60 * 1000; + +/* ---- PEM helpers ---- */ + +/** Accept a PEM string, or base64 of one (Railway variables are single-line). */ +function pemFromEnv(value) { + const v = String(value || "").trim(); + if (!v) return ""; + if (v.includes("-----BEGIN")) return v.replace(/\\n/g, "\n"); + try { + const decoded = Buffer.from(v, "base64").toString("utf8"); + return decoded.includes("-----BEGIN") ? decoded : ""; + } catch { + return ""; + } +} + +function derFromPem(pem, label) { + const blocks = x509.PemConverter.decode(pem); + if (!blocks.length) throw new Error(`${label}: not PEM`); + return blocks[0]; +} + +/** SHA-256 over the SubjectPublicKeyInfo, base64 -- the registry's pin format. */ +export function pinOf(cert) { + return createHash("sha256").update(Buffer.from(cert.publicKey.rawData)).digest("base64"); +} + +export function fingerprintOf(cert) { + return createHash("sha256").update(Buffer.from(cert.rawData)).digest("hex").toUpperCase().match(/../g).join(":"); +} + +function serialNumber() { + const b = randomBytes(16); + b[0] &= 0x7f; // positive INTEGER + return b.toString("hex"); +} + +/* ---- the CA in use ---- */ + +let current = null; // { issuer: X509Certificate, key: CryptoKey, root: X509Certificate, leafDays } +let loaded = false; + +/** + * Load the issuing CA from configuration. Missing configuration means the CA + * is off: the endpoints answer 503 and nothing else changes. + */ +async function loadFromConfig() { + loaded = true; + const cfg = config.moshpitCa || {}; + const certPem = pemFromEnv(cfg.cert); + const keyPem = pemFromEnv(cfg.key); + const rootPem = pemFromEnv(cfg.root); + if (!certPem || !keyPem || !rootPem) return null; + return configureCa({ cert: certPem, key: keyPem, root: rootPem, leafDays: cfg.leafDays }); +} + +/** + * Install an issuing CA explicitly (tests, or a future key rotation without a + * restart). `key` is a PKCS#8 PEM; `cert` and `root` are certificate PEMs. + */ +export async function configureCa({ cert, key, root, leafDays = 30 }) { + const issuer = new x509.X509Certificate(cert); + const rootCert = new x509.X509Certificate(root); + const keyDer = derFromPem(key, "MOSHPIT_CA_KEY"); + const privateKey = await webcrypto.subtle.importKey("pkcs8", keyDer, CA_ALG, true, ["sign"]); + // The certificate must belong to the key, or every leaf signed here would + // fail to chain and nobody would know why until a browser said so. WebCrypto + // has no "public half of this private key", so go through JWK. + const jwk = await webcrypto.subtle.exportKey("jwk", privateKey); + delete jwk.d; + jwk.key_ops = ["verify"]; + const publicKey = await webcrypto.subtle.importKey("jwk", jwk, CA_ALG, true, ["verify"]); + const spkiOfKey = await webcrypto.subtle.exportKey("spki", publicKey); + if (Buffer.compare(Buffer.from(spkiOfKey), Buffer.from(issuer.publicKey.rawData)) !== 0) { + throw new Error("MOSHPIT_CA_KEY does not match MOSHPIT_CA_CERT"); + } + if (!(await issuer.verify({ publicKey: await rootCert.publicKey.export() }))) { + throw new Error("MOSHPIT_CA_CERT is not signed by MOSHPIT_CA_ROOT"); + } + current = { issuer, key: privateKey, root: rootCert, leafDays: Math.max(1, Math.min(Number(leafDays) || 30, 398)) }; + loaded = true; + return current; +} + +export function resetCaForTests() { + current = null; + loaded = false; +} + +async function ca() { + if (!loaded) current = await loadFromConfig(); + return current; +} + +export async function caEnabled() { + return Boolean(await ca()); +} + +/** What a client needs to trust the namespace: the root, and the chain an origin serves. */ +export async function caMaterial() { + const c = await ca(); + if (!c) return null; + return { + root: c.root.toString("pem"), + issuer: c.issuer.toString("pem"), + chain: `${c.issuer.toString("pem")}\n${c.root.toString("pem")}\n`, + rootSubject: c.root.subject, + issuerSubject: c.issuer.subject, + rootFingerprint: fingerprintOf(c.root), + rootNotAfter: c.root.notAfter.getTime(), + leafDays: c.leafDays, + }; +} + +/* ---- issuing ---- */ + +/** + * Sign a leaf for `label.tld` from a PEM CSR. + * + * Returns { cert, chain, serial, notBefore, notAfter, pin } or { error }. + * The subject is taken from the name, never from the CSR: the caller has + * already proved control of the name, and letting the CSR's own subject or SAN + * through would let a holder of blue.eggs ask for a leaf naming red.eggs. + */ +export async function issueLeaf({ tld, label, csr: csrPem }) { + const c = await ca(); + if (!c) return { error: "certificate authority is not configured", status: 503 }; + if (isRealTld(tld)) return { error: `.${tld} is a real top-level domain; the pit does not sign for it`, status: 400 }; + + let csr; + try { + csr = new x509.Pkcs10CertificateRequest(String(csrPem || "")); + } catch { + return { error: "csr must be a PEM PKCS#10 certificate request", status: 400 }; + } + if (!(await csr.verify())) return { error: "csr signature does not verify", status: 400 }; + + const publicKey = await csr.publicKey.export(); + const alg = csr.publicKey.algorithm || {}; + const rsa = /rsa/i.test(alg.name || ""); + if (rsa && (alg.modulusLength || 0) < 2048) return { error: "RSA keys must be 2048 bits or more", status: 400 }; + if (!rsa && !/ec/i.test(alg.name || "")) return { error: `unsupported key type ${alg.name || "?"}`, status: 400 }; + + const name = `${label}.${tld}`; + const notBefore = new Date(Date.now() - 5 * 60 * 1000); // clock skew + const notAfter = new Date(Date.now() + c.leafDays * DAY); + const serial = serialNumber(); + + const cert = await x509.X509CertificateGenerator.create({ + serialNumber: serial, + subject: `CN=${name}`, + issuer: c.issuer.subject, + notBefore, + notAfter, + signingAlgorithm: CA_ALG, + publicKey, + signingKey: c.key, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, true), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.digitalSignature | (rsa ? x509.KeyUsageFlags.keyEncipherment : 0), + true, + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth]), + new x509.SubjectAlternativeNameExtension([ + { type: "dns", value: name }, + { type: "dns", value: `*.${name}` }, + ]), + await x509.SubjectKeyIdentifierExtension.create(publicKey), + await x509.AuthorityKeyIdentifierExtension.create(c.issuer), + ], + }); + + return { + cert: cert.toString("pem"), + chain: `${cert.toString("pem")}\n${c.issuer.toString("pem")}\n${c.root.toString("pem")}\n`, + serial, + notBefore: notBefore.getTime(), + notAfter: notAfter.getTime(), + pin: pinOf(cert), + }; +} + +/* ---- creating a CA (scripts/moshpit-ca-init.mjs, and tests) ---- */ + +async function exportPem(key, kind) { + const der = await webcrypto.subtle.exportKey(kind, key); + return x509.PemConverter.encode(der, kind === "pkcs8" ? "PRIVATE KEY" : "PUBLIC KEY"); +} + +/** + * A root and an issuing intermediate. Returns PEMs; the root key is meant to + * go somewhere cold, the issuing pair into the server's environment. + */ +export async function generateCa({ rootName = "Moshpit Root CA", issuerName = "Moshpit Issuing CA", rootYears = 20, issuerYears = 10, now = new Date() } = {}) { + const rootKeys = await webcrypto.subtle.generateKey(CA_ALG, true, ["sign", "verify"]); + const root = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: serialNumber(), + name: `CN=${rootName}, O=Moshpit`, + notBefore: now, + notAfter: new Date(now.getTime() + rootYears * 365 * DAY), + signingAlgorithm: CA_ALG, + keys: rootKeys, + extensions: [ + new x509.BasicConstraintsExtension(true, 1, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(rootKeys.publicKey), + ], + }); + + const issuerKeys = await webcrypto.subtle.generateKey(CA_ALG, true, ["sign", "verify"]); + const issuer = await x509.X509CertificateGenerator.create({ + serialNumber: serialNumber(), + subject: `CN=${issuerName}, O=Moshpit`, + issuer: root.subject, + notBefore: now, + notAfter: new Date(now.getTime() + issuerYears * 365 * DAY), + signingAlgorithm: CA_ALG, + publicKey: issuerKeys.publicKey, + signingKey: rootKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(true, 0, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(issuerKeys.publicKey), + await x509.AuthorityKeyIdentifierExtension.create(root), + ], + }); + + return { + root: { cert: root.toString("pem"), key: await exportPem(rootKeys.privateKey, "pkcs8") }, + issuer: { cert: issuer.toString("pem"), key: await exportPem(issuerKeys.privateKey, "pkcs8") }, + }; +} + +/** A CSR for `name`, for the origin script's counterpart in tests. */ +export async function generateCsr(name, keys = null) { + const k = keys || await webcrypto.subtle.generateKey(CA_ALG, true, ["sign", "verify"]); + const csr = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${name}`, + keys: k, + signingAlgorithm: CA_ALG, + }); + return { csr: csr.toString("pem"), keys: k, key: await exportPem(k.privateKey, "pkcs8") }; +} diff --git a/apps/pwa/src/lib/moshpit-certs.mjs b/apps/pwa/src/lib/moshpit-certs.mjs new file mode 100644 index 00000000..79879ed5 --- /dev/null +++ b/apps/pwa/src/lib/moshpit-certs.mjs @@ -0,0 +1,83 @@ +// Issuing certificates for names: the part of the CA that knows about people. +// +// moshpit-ca.mjs signs; this decides who may ask. The rule is the one pins +// already use, controlledName(): the name's holder, or its tenant while a lease +// runs. That is what makes the resale case safe -- whoever holds `.foo` cannot +// obtain a certificate for `bar.foo` once it is somebody else's -- and it is +// why the CA lives in the registry rather than with ending owners. +// +// Every leaf is also published as a pin, so a client that still checks pins +// keeps working through the switch, and the two records never disagree. +import { all, get, run } from "../db.mjs"; +import { addPin, controlledName, logAction, normalizeLabel, normalizeTld } from "../moshpit.mjs"; +import { issueLeaf } from "./moshpit-ca.mjs"; + +const CERT_COLS = `serial, tld, label, pin, cert, user_id, not_before, not_after, issued_at`; +const DAY = 24 * 60 * 60 * 1000; + +/** Issuances allowed per name per day. A renewal loop gone wrong, not a user, is what this catches. */ +export const MAX_CERTS_PER_DAY = 24; + +/** + * Sign a certificate for `label.tld` on behalf of `userId`. + * + * Returns { ok: true, name, serial, cert, chain, notBefore, notAfter, pin } + * or { ok: false, error, status }. + */ +export async function issueNameCertificate({ tld: tldInput, label: labelInput, userId, csr, now = Date.now() }) { + const owned = await controlledName(tldInput, labelInput, userId, now); + if (!owned.ok) return { ok: false, error: owned.error, status: 403 }; + const { tld, label } = owned; + + const recent = await get( + `SELECT COUNT(*) AS n FROM moshpit_name_certs WHERE tld = ? AND label = ? AND issued_at > ?`, + [tld, label, now - DAY], + ); + if ((recent?.n ?? 0) >= MAX_CERTS_PER_DAY) { + return { ok: false, error: `${label}.${tld} has been issued ${MAX_CERTS_PER_DAY} certificates in the last day; try again later`, status: 429 }; + } + + const leaf = await issueLeaf({ tld, label, csr }); + if (leaf.error) return { ok: false, error: leaf.error, status: leaf.status || 400 }; + + await run( + `INSERT INTO moshpit_name_certs (${CERT_COLS}) VALUES (?,?,?,?,?,?,?,?,?)`, + [leaf.serial, tld, label, leaf.pin, leaf.cert, userId, leaf.notBefore, leaf.notAfter, now], + ); + // The pin is the same key the certificate carries; publishing it keeps every + // pin-checking client (moshcode dns trust, the pit helper) in step. A pin that + // is already there is fine; one already published as a different kind is not + // ours to fix here, and the certificate is still valid without it. + const pinned = await addPin({ tld, label, pin: leaf.pin, kind: "tls", note: "moshpit-ca", userId }); + await logAction(tld, userId, `cert:issue:${label}:${leaf.serial.slice(0, 8)}`); + + return { + ok: true, + name: `${label}.${tld}`, + serial: leaf.serial, + cert: leaf.cert, + chain: leaf.chain, + notBefore: leaf.notBefore, + notAfter: leaf.notAfter, + pin: leaf.pin, + pinPublished: Boolean(pinned.ok), + }; +} + +/** What has been issued under a name, newest first. Public: certificates are. */ +export async function listNameCertificates(tldInput, labelInput, { limit = 50, now = Date.now() } = {}) { + const tld = normalizeTld(tldInput); + const label = normalizeLabel(labelInput); + if (!tld || !label) return []; + const rows = await all( + `SELECT serial, pin, not_before, not_after, issued_at FROM moshpit_name_certs + WHERE tld = ? AND label = ? ORDER BY issued_at DESC LIMIT ?`, + [tld, label, limit], + ); + return rows.map((r) => ({ ...r, expired: r.not_after <= now })); +} + +/** 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 || "")]); +} diff --git a/apps/pwa/src/migrations/024_moshpit_certs.sql b/apps/pwa/src/migrations/024_moshpit_certs.sql new file mode 100644 index 00000000..f5c2a6ff --- /dev/null +++ b/apps/pwa/src/migrations/024_moshpit_certs.sql @@ -0,0 +1,27 @@ +-- Certificates the pit has signed. +-- +-- The registry is the one party that knows who holds a name, so it is the +-- natural authority to sign that name's certificate: a client that trusts the +-- pit's root once trusts every name, instead of learning each one against its +-- pin. This is the record of what was signed, for whom, and until when. +-- +-- Nothing here is consulted on the TLS path. Leaves are short-lived (30 days) +-- and renewed by the origin, so there is no revocation list to serve; the +-- table exists so an owner can see what is out under their name, so a renewal +-- loop can be spotted, and so an issuance can be answered for after the fact. +-- +-- The PEM is kept so a lost certificate can be fetched again without a new +-- issuance, which matters for a name whose key is on a box that only has curl. +CREATE TABLE IF NOT EXISTS moshpit_name_certs ( + serial TEXT PRIMARY KEY, + tld TEXT NOT NULL, + label TEXT NOT NULL, + pin TEXT NOT NULL, + cert TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + not_before INTEGER NOT NULL, + not_after INTEGER NOT NULL, + issued_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_moshpit_name_certs_name ON moshpit_name_certs(tld, label, issued_at); +CREATE INDEX IF NOT EXISTS idx_moshpit_name_certs_user ON moshpit_name_certs(user_id); diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index 3cf0d329..94d73a0a 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -230,7 +230,7 @@ export async function registerTld({ tld: input, userId, ownerEmail = null, owner return created ? { ok: true, tld: created } : { ok: false, error: "registered but could not be read back" }; } -const logAction = (tld, userId, action) => +export const logAction = (tld, userId, action) => run(`INSERT INTO moshpit_tld_log (tld, user_id, action, at) VALUES (?,?,?,?)`, [tld, userId, action, Date.now()]); /** @@ -2367,7 +2367,7 @@ export async function listLeasesForUser(userId) { * or change who is contacted about buying it. Those stay with the holder, and * they stay on `ownedName`. */ -async function controlledName(tldInput, labelInput, userId, now = Date.now()) { +export async function controlledName(tldInput, labelInput, userId, now = Date.now()) { const tld = normalizeTld(tldInput); const label = normalizeLabel(labelInput); if (!tld || !label) return { ok: false, error: "not a valid name" }; diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 469d08c0..b43dd958 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -175,6 +175,8 @@ import { } from "../moshpit.mjs"; import { config } from "../config.mjs"; import { shortLinkUrl } from "../lib/moshpit-links.mjs"; +import { caEnabled, caMaterial } from "../lib/moshpit-ca.mjs"; +import { getNameCertificate, issueNameCertificate, listNameCertificates } from "../lib/moshpit-certs.mjs"; export const moshpitRouter = Router(); @@ -1767,6 +1769,102 @@ moshpitRouter.get("/api/moshpit/tlds/:tld/pins", async (req, res) => { res.json({ tld, label, pins: await listPins(tld, label, kind) }); }); +/* ---------- the certificate authority ---------- */ + +/** + * GET /api/moshpit/ca — public. Whether the pit signs, and what to install. + * + * `enabled: false` is a real answer, not an outage: a registry without a + * configured CA still resolves and still publishes pins. Clients that want + * https on pit names read this, install the root from /api/moshpit/ca.crt, and + * from then on trust every name at once instead of one pin at a time. + */ +moshpitRouter.get("/api/moshpit/ca", async (_req, res) => { + const m = await caMaterial(); + if (!m) return res.json({ enabled: false }); + res.json({ + enabled: true, + root: { subject: m.rootSubject, fingerprint_sha256: m.rootFingerprint, not_after: m.rootNotAfter, url: "/api/moshpit/ca.crt" }, + issuer: { subject: m.issuerSubject }, + chain_url: "/api/moshpit/ca-chain.crt", + leaf_days: m.leafDays, + issue: "POST /api/moshpit/tlds/:tld/certs { label, csr }", + }); +}); + +const pem = (res, body, filename) => { + res.setHeader("content-type", "application/x-pem-file; charset=utf-8"); + res.setHeader("content-disposition", `inline; filename="${filename}"`); + res.setHeader("cache-control", "public, max-age=3600"); + res.send(body); +}; + +/** GET /api/moshpit/ca.crt — the root, PEM. What a client installs. */ +moshpitRouter.get("/api/moshpit/ca.crt", async (_req, res) => { + const m = await caMaterial(); + if (!m) return res.status(503).json({ error: "certificate authority is not configured" }); + pem(res, m.root, "moshpit-root-ca.crt"); +}); + +/** GET /api/moshpit/ca-chain.crt — issuer then root, PEM. What an origin serves after its leaf. */ +moshpitRouter.get("/api/moshpit/ca-chain.crt", async (_req, res) => { + const m = await caMaterial(); + if (!m) return res.status(503).json({ error: "certificate authority is not configured" }); + pem(res, m.chain, "moshpit-ca-chain.crt"); +}); + +/** + * POST /api/moshpit/tlds/:tld/certs { label, csr } — sign a certificate. + * + * The caller proves control of the name by being who they are: the same rule + * as pins, the holder or the current tenant. The CSR proves they hold the key. + * Nothing in the CSR names the certificate -- the name does -- so a request for + * blue.eggs cannot come out naming red.eggs however the CSR is written. + * + * Answers with the leaf, the chain to serve (leaf + issuer + root), the root on + * its own, and the pin the leaf's key was published under. + */ +moshpitRouter.post("/api/moshpit/tlds/:tld/certs", async (req, res) => { + if (!req.user) return unauthorized(res); + if (!(await caEnabled())) return res.status(503).json({ error: "certificate authority is not configured" }); + const csr = typeof req.body?.csr === "string" ? req.body.csr : ""; + if (csr.length > 16384) return bad(res, "csr is too large"); + const result = await issueNameCertificate({ + tld: req.params.tld, + label: req.body?.label, + userId: req.user.id, + csr, + }); + if (!result.ok) return bad(res, result.error, result.status || 400); + const m = await caMaterial(); + res.status(201).json({ + name: result.name, + serial: result.serial, + cert: result.cert, + chain: result.chain, + root: m?.root ?? null, + not_before: result.notBefore, + not_after: result.notAfter, + pin: result.pin, + pin_published: result.pinPublished, + }); +}); + +/** GET /api/moshpit/tlds/:tld/certs?label=blue — public; what has been issued under a name. */ +moshpitRouter.get("/api/moshpit/tlds/:tld/certs", async (req, res) => { + const tld = normalizeTld(req.params.tld); + const label = normalizeLabel(req.query.label); + if (!tld || !label) return bad(res, "tld and label are required"); + res.json({ tld, label, certs: await listNameCertificates(tld, label) }); +}); + +/** GET /api/moshpit/certs/:serial — one issued certificate, PEM. */ +moshpitRouter.get("/api/moshpit/certs/:serial", async (req, res) => { + const row = await getNameCertificate(req.params.serial); + if (!row) return res.status(404).json({ error: "no such certificate" }); + pem(res, row.cert, `${row.label}.${row.tld}.crt`); +}); + /** * POST /api/moshpit/tlds/:tld/pins { label, pin, kind, note? } — publish a key. * diff --git a/apps/pwa/test/moshpit-ca-route.test.mjs b/apps/pwa/test/moshpit-ca-route.test.mjs new file mode 100644 index 00000000..10bca60f --- /dev/null +++ b/apps/pwa/test/moshpit-ca-route.test.mjs @@ -0,0 +1,144 @@ +// The certificate authority over HTTP: what an origin script and a client see. +// +// Boots the real moshpit router against a throwaway libsql file and a CA made +// in memory, because the things worth being sure of are the wire shapes: the +// 503 when the CA is off, PEM with the right content type for the root and +// chain, a bearer key minting a leaf for its own name and not for somebody +// else's, and the issued list. +// +// Skips cleanly when the PWA dependencies are not installed. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { X509Certificate } from "node:crypto"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; + require("@peculiar/x509"); +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-ca-route-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; +process.env.PIT_ORIGIN = "https://pit.moshcode.sh"; +delete process.env.MOSHPIT_CA_CERT; +delete process.env.MOSHPIT_CA_KEY; +delete process.env.MOSHPIT_CA_ROOT; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, db } = await import("../src/db.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const { moshpitRouter } = await import("../src/routes/moshpit.mjs"); + const { createApiKey } = await import("../src/lib/apikey.mjs"); + const moshpit = await import("../src/moshpit.mjs"); + const ca = await import("../src/lib/moshpit-ca.mjs"); + + const app = deps.express(); + app.use(deps.express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } })); + app.use(deps.express.urlencoded({ extended: false })); + app.use(deps.cookieParser()); + app.use(sessionMiddleware); + app.use(csrfGuard); + app.use(moshpitRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + + await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','one',1)`); + await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u2','d@e.f','two',1)`); + const mine = (await createApiKey("u1", "test")).plaintext; + const theirs = (await createApiKey("u2", "test")).plaintext; + assert.equal((await moshpit.registerTld({ tld: "eggs", userId: "u1" })).ok, true); + assert.equal((await moshpit.registerName({ tld: "eggs", label: "blue", userId: "u1" })).ok, true); + + const json = (res) => res.json().then((body) => ({ status: res.status, body, headers: res.headers })); + const issue = (body, key = mine) => fetch(`${base}/api/moshpit/tlds/eggs/certs`, { + method: "POST", + headers: { "content-type": "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) }, + body: JSON.stringify(body), + }).then(json); + const getText = (p) => fetch(`${base}${p}`).then(async (res) => ({ status: res.status, text: await res.text(), headers: res.headers })); + + return { server, db, base, mine, theirs, issue, getText, json, ca, moshpit }; +} + +let booted = null; +const app = () => (booted ||= boot()); +const skip = !deps && "apps/pwa deps not installed"; + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +test("with no CA configured, /api/moshpit/ca says so and issuing is 503", { skip }, async () => { + const { base, getText, issue, ca } = await app(); + ca.resetCaForTests(); + const status = await fetch(`${base}/api/moshpit/ca`).then((r) => r.json()); + assert.deepEqual(status, { enabled: false }); + assert.equal((await getText("/api/moshpit/ca.crt")).status, 503); + const r = await issue({ label: "blue", csr: "x" }); + assert.equal(r.status, 503); +}); + +test("once configured: root and chain are served as PEM, and a key mints a leaf for its own name", { skip }, async () => { + const { base, getText, issue, ca, theirs } = await app(); + const made = await ca.generateCa({ rootName: "Route Test Root" }); + await ca.configureCa({ cert: made.issuer.cert, key: made.issuer.key, root: made.root.cert }); + + const status = await fetch(`${base}/api/moshpit/ca`).then((r) => r.json()); + assert.equal(status.enabled, true); + assert.match(status.root.subject, /Route Test Root/); + assert.equal(status.leaf_days, 30); + + const root = await getText("/api/moshpit/ca.crt"); + assert.equal(root.status, 200); + assert.match(root.headers.get("content-type"), /x-pem-file/); + assert.equal(new X509Certificate(root.text).fingerprint256, new X509Certificate(made.root.cert).fingerprint256); + + const chain = await getText("/api/moshpit/ca-chain.crt"); + assert.equal(chain.text.match(/BEGIN CERTIFICATE/g).length, 2); + + const { csr } = await ca.generateCsr("blue.eggs"); + const r = await issue({ label: "blue", csr }); + assert.equal(r.status, 201, JSON.stringify(r.body)); + assert.equal(r.body.name, "blue.eggs"); + const leaf = new X509Certificate(r.body.cert); + assert.ok(leaf.checkHost("blue.eggs")); + assert.ok(leaf.verify(new X509Certificate(made.issuer.cert).publicKey)); + assert.equal(r.body.chain.match(/BEGIN CERTIFICATE/g).length, 3); + assert.equal(r.body.pin_published, true); + + // Somebody else's key: refused with the ownership reason, nothing issued. + const other = await issue({ label: "blue", csr }, theirs); + assert.equal(other.status, 403); + assert.match(other.body.error, /do not own/); + + // No key at all. + const anon = await issue({ label: "blue", csr }, null); + assert.equal(anon.status, 401); + + // The issued list is public and the certificate can be fetched back by serial. + const list = await fetch(`${base}/api/moshpit/tlds/eggs/certs?label=blue`).then((x) => x.json()); + assert.equal(list.certs.length, 1); + assert.equal(list.certs[0].serial, r.body.serial); + const again = await getText(`/api/moshpit/certs/${r.body.serial}`); + assert.equal(again.status, 200); + assert.equal(new X509Certificate(again.text).fingerprint256, leaf.fingerprint256); + + // The pin the leaf carries is what /api/moshpit/pins now publishes. + const pins = await fetch(`${base}/api/moshpit/pins?name=blue.eggs`).then((x) => x.json()); + assert.deepEqual(pins.pins, [r.body.pin]); +}); diff --git a/apps/pwa/test/moshpit-ca.test.mjs b/apps/pwa/test/moshpit-ca.test.mjs new file mode 100644 index 00000000..7a1a9398 --- /dev/null +++ b/apps/pwa/test/moshpit-ca.test.mjs @@ -0,0 +1,197 @@ +// The Moshpit certificate authority, against a real (throwaway) libSQL database +// and a CA generated in memory. +// +// What is worth checking is what a browser will check: that a leaf chains to +// the root, names the right host and nothing else, is not a CA, and that only +// the person who controls the name can obtain one -- including the resale +// case, where the ending's owner must not get a certificate for a name they +// sold, and the lease case, where the tenant may and the holder may not. +// +// Skips cleanly when the PWA dependencies are not installed. +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { X509Certificate } from "node:crypto"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let installed = true; +try { require("@libsql/client"); require("@peculiar/x509"); } catch { installed = false; } + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-ca-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const ALICE = "user-alice"; // owns .eggs and blue.eggs +const BOB = "user-bob"; // bought red.eggs +const DAY = 24 * 60 * 60 * 1000; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run } = await import("../src/db.mjs"); + for (const [id, email] of [[ALICE, "alice@example.com"], [BOB, "bob@example.com"]]) { + await run(`INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, Date.now()]); + } + return { + moshpit: await import("../src/moshpit.mjs"), + ca: await import("../src/lib/moshpit-ca.mjs"), + certs: await import("../src/lib/moshpit-certs.mjs"), + run, + }; +} + +test("moshpit certificate authority", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => { + const { moshpit, ca, certs, run } = await boot(); + + const generated = await ca.generateCa({ rootName: "Test Root", issuerName: "Test Issuer" }); + const root = new X509Certificate(generated.root.cert); + const issuer = new X509Certificate(generated.issuer.cert); + + await t.test("the generated CA is a real chain", () => { + assert.equal(root.ca, true); + assert.equal(issuer.ca, true); + assert.ok(issuer.verify(root.publicKey), "issuer is signed by the root"); + assert.ok(root.verify(root.publicKey), "root is self-signed"); + assert.match(issuer.issuer, /Test Root/); + }); + + await t.test("the CA refuses a key that does not match its certificate", async () => { + const other = await ca.generateCa(); + await assert.rejects( + ca.configureCa({ cert: generated.issuer.cert, key: other.issuer.key, root: generated.root.cert }), + /does not match/, + ); + }); + + await t.test("nothing is signed while the CA is not configured", async () => { + ca.resetCaForTests(); + delete process.env.MOSHPIT_CA_CERT; + const r = await ca.issueLeaf({ tld: "eggs", label: "blue", csr: "" }); + assert.equal(r.status, 503); + }); + + await ca.configureCa({ cert: generated.issuer.cert, key: generated.issuer.key, root: generated.root.cert, leafDays: 30 }); + + assert.equal((await moshpit.registerTld({ tld: "eggs", userId: ALICE })).ok, true); + assert.equal((await moshpit.registerName({ tld: "eggs", label: "blue", userId: ALICE })).ok, true); + assert.equal((await moshpit.registerName({ tld: "eggs", label: "red", userId: ALICE })).ok, true); + // red.eggs is sold to Bob: the row's holder changes, the ending stays Alice's. + await run(`UPDATE moshpit_names SET user_id = ? WHERE tld = 'eggs' AND label = 'red'`, [BOB]); + + let blueLeaf; + await t.test("the holder gets a leaf that a browser would accept", async () => { + const { csr } = await ca.generateCsr("blue.eggs"); + const r = await certs.issueNameCertificate({ tld: "eggs", label: "blue", userId: ALICE, csr }); + assert.equal(r.ok, true, r.error); + blueLeaf = new X509Certificate(r.cert); + assert.equal(blueLeaf.ca, false, "a leaf, never a CA"); + assert.ok(blueLeaf.verify(issuer.publicKey), "signed by the issuing CA"); + assert.equal(blueLeaf.subject, "CN=blue.eggs"); + assert.ok(blueLeaf.checkHost("blue.eggs"), "names the host"); + assert.ok(blueLeaf.checkHost("www.blue.eggs"), "and everything under it"); + assert.equal(blueLeaf.checkHost("red.eggs"), undefined, "and nothing else"); + assert.match(blueLeaf.keyUsage.join(","), /1\.3\.6\.1\.5\.5\.7\.3\.1/, "serverAuth"); + const days = (new Date(blueLeaf.validTo).getTime() - new Date(blueLeaf.validFrom).getTime()) / DAY; + assert.ok(days > 29 && days < 31.1, `short-lived, got ${days} days`); + // The chain the origin serves: leaf, issuer, root, in that order. + const pems = r.chain.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + assert.equal(pems.length, 3); + assert.equal(new X509Certificate(pems[1]).fingerprint256, issuer.fingerprint256); + assert.equal(new X509Certificate(pems[2]).fingerprint256, root.fingerprint256); + }); + + await t.test("the leaf's key is published as a pin, so pin clients keep working", async () => { + const pins = await moshpit.listPins("eggs", "blue", "tls"); + assert.equal(pins.length, 1); + assert.equal(pins[0].pin, ca.pinOf(new (await import("@peculiar/x509")).X509Certificate(blueLeaf.raw))); + assert.equal(pins[0].note, "moshpit-ca"); + }); + + await t.test("the CSR's own subject never decides the name", async () => { + const { csr } = await ca.generateCsr("red.eggs"); // Alice asks for blue but the CSR says red + const r = await certs.issueNameCertificate({ tld: "eggs", label: "blue", userId: ALICE, csr }); + assert.equal(r.ok, true, r.error); + const leaf = new X509Certificate(r.cert); + assert.equal(leaf.subject, "CN=blue.eggs"); + assert.equal(leaf.checkHost("red.eggs"), undefined); + }); + + await t.test("the ending's owner cannot get a certificate for a name they sold", async () => { + const { csr } = await ca.generateCsr("red.eggs"); + const r = await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: ALICE, csr }); + assert.equal(r.ok, false); + assert.match(r.error, /do not own red\.eggs/); + assert.equal(r.status, 403); + }); + + await t.test("the buyer can", async () => { + const { csr } = await ca.generateCsr("red.eggs"); + const r = await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: BOB, csr }); + assert.equal(r.ok, true, r.error); + assert.ok(new X509Certificate(r.cert).checkHost("red.eggs")); + }); + + await t.test("during a lease the tenant may and the holder may not", async () => { + await run(`UPDATE moshpit_names SET leased_to = ?, leased_until = ? WHERE tld = 'eggs' AND label = 'blue'`, [BOB, Date.now() + 30 * DAY]); + const { csr } = await ca.generateCsr("blue.eggs"); + const holder = await certs.issueNameCertificate({ tld: "eggs", label: "blue", userId: ALICE, csr }); + assert.equal(holder.ok, false); + assert.match(holder.error, /leased until/); + const tenant = await certs.issueNameCertificate({ tld: "eggs", label: "blue", userId: BOB, csr }); + assert.equal(tenant.ok, true, tenant.error); + await run(`UPDATE moshpit_names SET leased_to = NULL, leased_until = NULL WHERE tld = 'eggs' AND label = 'blue'`); + }); + + await t.test("a real top-level domain is never signed, whatever the registry says", async () => { + const r = await ca.issueLeaf({ tld: "com", label: "google", csr: (await ca.generateCsr("google.com")).csr }); + assert.equal(r.status, 400); + assert.match(r.error, /real top-level domain/); + }); + + await t.test("a CSR that does not verify, or is not a CSR, is refused", async () => { + const bad = await ca.issueLeaf({ tld: "eggs", label: "blue", csr: "-----BEGIN CERTIFICATE REQUEST-----\nnope\n-----END CERTIFICATE REQUEST-----" }); + assert.equal(bad.status, 400); + const { csr } = await ca.generateCsr("blue.eggs"); + const tampered = csr.replace(/A/g, "B"); + const t2 = await ca.issueLeaf({ tld: "eggs", label: "blue", csr: tampered }); + assert.equal(t2.status, 400); + }); + + await t.test("an unregistered name gets nothing", async () => { + const { csr } = await ca.generateCsr("nobody.eggs"); + const r = await certs.issueNameCertificate({ tld: "eggs", label: "nobody", userId: ALICE, csr }); + assert.equal(r.ok, false); + assert.match(r.error, /not registered/); + }); + + await t.test("issuance is recorded and listable", async () => { + const list = await certs.listNameCertificates("eggs", "blue"); + assert.ok(list.length >= 2); + assert.equal(list[0].expired, false); + const one = await certs.getNameCertificate(list[0].serial); + assert.match(one.cert, /BEGIN CERTIFICATE/); + }); + + await t.test("a runaway renewal loop is stopped", async () => { + const { csr } = await ca.generateCsr("red.eggs"); + let last; + for (let i = 0; i < certs.MAX_CERTS_PER_DAY + 1; i++) { + last = await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: BOB, csr }); + if (!last.ok) break; + } + assert.equal(last.ok, false); + assert.equal(last.status, 429); + }); + + await t.test("the material a client installs is the root, and the chain is issuer then root", async () => { + const m = await ca.caMaterial(); + assert.equal(new X509Certificate(m.root).fingerprint256, root.fingerprint256); + assert.match(m.rootSubject, /Test Root/); + assert.equal(m.rootFingerprint, root.fingerprint256); + const pems = m.chain.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + assert.equal(pems.length, 2); + }); +}); From 6d5257b7292985af1ba2ee40af29e69d5ebc4a65 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 16 Sep 2026 16:30:13 +0000 Subject: [PATCH 2/2] pwa: record @peculiar/x509 in pnpm-lock.yaml, which CI installs with --frozen-lockfile Co-Authored-By: Claude Fable 5.1 --- apps/pwa/pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/pwa/pnpm-lock.yaml b/apps/pwa/pnpm-lock.yaml index 60ba0a4c..7b53ffdb 100644 --- a/apps/pwa/pnpm-lock.yaml +++ b/apps/pwa/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@libsql/client': specifier: ^0.14.0 version: 0.14.0 + '@peculiar/x509': + specifier: ^1.14.3 + version: 1.14.3 '@profullstack/synconfig': specifier: ^0.1.1 version: 0.1.1