Skip to content

feat(dns): trust a name on the strength of its pin, and stop blaming the registry - #279

Draft
ralyodio wants to merge 1 commit into
mainfrom
fix/first-run-trust
Draft

feat(dns): trust a name on the strength of its pin, and stop blaming the registry#279
ralyodio wants to merge 1 commit into
mainfrom
fix/first-run-trust

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Two failures from one first run, neither of which the machine could explain.

1. "no TLDs claimed yet" was not true

$ moshcode dns enable
routing every lookup here — no bridge is running yet — this one will be ours
no TLDs claimed yet — nothing to route

The registry holds 5674 endings. The fetch had failed, and the failure was swallowed:

try { tlds = await fetchTldsImpl({ registryBase }); }
catch { tlds = []; }

So a network problem was reported as a fact about the registry — which sends someone off to claim an ending they already own. This is the same bug fixed in dns start, in the command next to it.

The two cases now read differently, and the failure prints the one-liner that settles it:

could not read the ending list from https://pit.moshcode.sh — fetch failed
  nothing has been changed. This is a failure to ask, not an empty registry:
  check with  curl -s 'https://pit.moshcode.sh/api/moshpit/tlds?limit=5&offset=0'

2. The certificate error survived everything

moshpit-proxy verifies the registry pin and re-signs with a local root. But nothing routes to it:

seo.rank        → 67.205.189.229      (the origin)
moshpit-proxy   → listening 127.0.0.1:8443
~/.moshpit/ca/  → does not exist

The resolver answers the origin, the proxy waits on loopback, and the two were never connected — moshpit-dns has no loopback mode at all. So the root installed by #275 signs nothing, and curl https://seo.rank meets the origin's self-signed certificate exactly as before. That was my error in #275: I assumed the proxy was in the path.

dns trust <name>

Closes it without the proxy. A Moshpit name serves a self-signed leaf whose SAN names only itself, so trusting it vouches for that one name — a far smaller grant than a root, and the reason this path needs no name-constraints argument to be defensible.

The pin check is what separates it from recklessness:

$ moshcode dns trust seo.rank
seo.rank — the served key matches a pin the registry publishes
  pin  QCXCr9ZWmbOXLvnbNHYvHFu97LKcUECb1I2HNsBzxug=
  ok   trusted — curl https://seo.rank now verifies without flags

and when it does not match, nothing is written and nothing is refreshed:

REFUSED — the served key (QCXC…) is not among the 1 pin(s) the registry publishes
  served  QCXC…
  pinned  NOTTHISONE=
  moshcode will not trust a certificate the registry does not vouch for.

Installing whatever answered the socket is the definition of trusting whoever reached the port first. Installing it only when the registry already vouches for that exact key is registry-backed trust — a stronger claim than domain validation ever made, since it proves registry ownership rather than momentary control of a socket.

Details that are deliberate:

  • Every published pin is accepted, not just the first. The registry lists the old pin beside the new one during a rotation precisely so a key can change without a flag day.
  • The pin is over the SubjectPublicKeyInfo, so re-issuing for the same key — longer expiry, an added name — does not invalidate a pin anybody holds.
  • An outage is reported as an outage, never as a rejected certificate. The answer to "the registry is down" is to wait, not to distrust a name. (Anchored in the test, because ECONNREFUSED contains the word REFUSED — the same confusion one layer down.)
  • One file per name, so removing trust for a name is removing a file, and a name that would escape the directory is refused rather than sanitised into an unrecognisable filename.
  • The public key is read with node's own X509 parser, not by piping a PEM back through a shell — the pin is the value the entire decision turns on.

Verified against the live registry

served cert pin     QCXCr9ZWmbOXLvnbNHYvHFu97LKcUECb1I2HNsBzxug=
registry publishes  QCXCr9ZWmbOXLvnbNHYvHFu97LKcUECb1I2HNsBzxug=   ← match
curl --cacert <leaf> https://seo.rank/   →  http=200               (was: error 60)

23 new tests in test/trust.test.mjs, generating real self-signed leaves with openssl. Suite green.

…the registry

Two failures from the same first run, neither of which the machine could
explain.

1. `dns enable` printed "no TLDs claimed yet — nothing to route" on a
machine where the registry holds 5674 endings. The fetch had failed and
the failure was swallowed to an empty array, so a network problem was
reported as a fact about the registry — sending someone to claim an
ending they already own. Same bug as the one fixed in `dns start`; this
is the other half of it. The two cases now read differently, and the
failure prints the curl that checks it.

2. The certificate error survived everything. moshpit-proxy verifies the
registry pin and re-signs with a local root, but nothing routes to it:
the resolver answers the origin, the proxy sits on loopback, and the two
were never connected. So the root installed by #275 signs nothing, and
`curl https://seo.rank` still meets the origin's self-signed certificate.

`dns trust <name>` closes that without the proxy at all. A Moshpit name
serves a self-signed leaf whose SAN names only itself, so trusting it
vouches for that one name — a far smaller grant than a root, and why this
path needs no name-constraints argument to be defensible.

The pin check is what separates it from recklessness. Installing whatever
answered the socket is trusting whoever reached the port first;
installing it only when the registry already vouches for that exact key
is registry-backed trust, which is a stronger claim than domain
validation ever made. Every published pin is accepted, not just the
first, because the registry lists the old one beside the new during a
rotation precisely so a key can change without a flag day.

A registry outage is reported as an outage, never as a rejected
certificate: the answer to "the registry is down" is to wait, not to
distrust a name.

Verified against the live registry — the key seo.rank serves matches the
pin the Pit publishes, and trusting that leaf takes curl from error 60 to
200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

88 finding(s)

HIGH/CRITICAL: 50 | MEDIUM: 38

Severity Rule Location
HIGH manifest-typosquat apps/pwa/package.json:19
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
HIGH secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
HIGH secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/approvals-credits.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/approvals-notify.test.mjs:26
HIGH secret-generic-credential apps/pwa/test/approvals-resolve-race.test.mjs:20
HIGH secret-generic-credential apps/pwa/test/auth-form-email.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/auth-form-email.test.mjs:33
HIGH secret-generic-credential apps/pwa/test/auth-page-error.test.mjs:36
HIGH secret-generic-credential apps/pwa/test/cli-device-token.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/cli-pages-balance.test.mjs:32
HIGH secret-generic-credential apps/pwa/test/cli-token.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/credits-pack.test.mjs:51
HIGH secret-generic-credential apps/pwa/test/credits-webhook-event-match.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/credits-webhook.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/csrf-input-escaping.test.mjs:31
HIGH secret-generic-credential apps/pwa/test/csrf-input-escaping.test.mjs:101
HIGH secret-generic-credential apps/pwa/test/logout-csrf.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/moshpit-api-key.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-bulk-claim.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-claim-full-name.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/moshpit-crawlable.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-ending-page.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-pins.test.mjs:22
HIGH secret-generic-credential apps/pwa/test/moshpit-pit-page.test.mjs:33
HIGH secret-generic-credential apps/pwa/test/moshpit-records-page.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-records.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-registry.test.mjs:20
HIGH secret-generic-credential apps/pwa/test/moshpit-related-endings.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-sales.test.mjs:16
HIGH secret-generic-credential apps/pwa/test/moshpit-search.test.mjs:74
HIGH secret-generic-credential apps/pwa/test/moshpit-terms.test.mjs:19
HIGH secret-generic-credential apps/pwa/test/moshpit-tlds-pagination.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/passkey-register-duplicate.test.mjs:38
HIGH secret-generic-credential apps/pwa/test/require-auth-next.test.mjs:31
HIGH secret-generic-credential apps/pwa/test/require-auth-next.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/sessions-output-seq.test.mjs:30
HIGH secret-generic-credential apps/pwa/test/sessions-paste.test.mjs:24
HIGH secret-generic-credential apps/pwa/test/sessions-stream-replay.test.mjs:34
HIGH secret-generic-credential apps/pwa/test/sessions.test.mjs:24
HIGH secret-generic-credential apps/pwa/test/signature.test.mjs:6
HIGH secret-generic-credential test/auth.test.mjs:13
HIGH secret-generic-credential test/auth.test.mjs:63
HIGH secret-generic-credential test/console-cookie-malformed.test.mjs:15
HIGH secret-generic-credential test/console.test.mjs:12
HIGH secret-generic-credential test/mirror.test.mjs:37
HIGH secret-generic-credential test/mirror.test.mjs:77

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

Snippets are redacted; ThreatCrush never prints matched credential material.

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.

1 participant