Skip to content

feat(dns): finish the job — install trust, not just routing - #275

Merged
ralyodio merged 3 commits into
mainfrom
feat/trust-local-ca
Aug 3, 2026
Merged

feat(dns): finish the job — install trust, not just routing#275
ralyodio merged 3 commits into
mainfrom
feat/trust-local-ca

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

curl https://seo.rank returned this, on a machine where DNS was working perfectly:

curl: (60) SSL certificate OpenSSL verify result: self-signed certificate (18)

dns enable pointed names at the resolver and stopped. So a Moshpit name resolved and then failed its TLS handshake — which to the person who typed the URL is not "DNS works", it is "still broken".

No CA is ever going to close this

The CA/Browser Forum Baseline Requirements banned certificates for non-IANA names. Issuance stopped in November 2015 and the survivors were revoked by October 2016, and a CA in a browser root store that issued for .rank would be distrusted for doing it. The rule is the penalty. This is not a matter of persuasion, budget, or trying a different CA.

It was already solved, just not connected

moshpit-proxy checks the origin's key against the pin the registry published for that name, then re-signs with a root it generated on this machine — because restating the result is the only language a stock client accepts. All of that exists and is tested.

What was missing is that nothing wired it up. moshcode's entire knowledge of moshpit-proxy before this PR was one string naming it as the installer of a drop-in file.

So this is wiring, not new crypto. dns enable now finds the local root, checks it, installs it into the trust stores, and reports each one.

The refusal is the feature

The only reason installing a local root is acceptable is one extension: nameConstraints, marked critical, permitting Moshpit endings and nothing else. With it, the worst a stolen key reaches is a name its holder already controls. Without it, it can vouch for your bank.

So moshcode refuses a root that lacks it, rather than warning:

✔ a root with no name constraints is refused, not warned about
✔ constraints that are not critical do not count
✔ an unconstrained root produces no steps at all

moshpit-proxy sets the constraint today and has its own test for it. This side still checks, because "upstream does it correctly" is not a property that can be assumed across a repo boundary — a regression there would otherwise become an unconstrained root installed automatically on every machine that ran dns enable. The tests generate real roots with openssl, constrained and not, and prove the guard rejects the bad one.

Non-critical constraints are treated as absent, because a verifier may ignore an extension it does not recognise — which makes the guarantee a comment.

Reported, not summarised

NSS and the system store are separate outcomes. NSS (Chrome, Firefox) is user-level and always lands; curl's store needs root. Collapsing them into one "installed" line while curl still refuses is precisely the failure that produced this report, so without root it does the half it can and names the half it cannot, with the command to finish.

It never fails enable. DNS is switched and verified by the time this runs. Failing the whole command over a trust store would roll back working resolution in order to fix a certificate — trading the larger thing for the smaller one.

--no-trust opts out.

Scope, stated plainly

This fixes machines you run it on. Nothing shippable can make a stranger's curl trust a Moshpit name — that is the Baseline Requirements, not a gap in our tooling. TronBrowser remains the answer for someone who has run nothing, since it verifies registry pins natively and needs no DNS config, no proxy and no root.

Tests

20 in test/trust.test.mjs; full suite 1323 pass / 0 fail.

`dns enable` pointed names at the resolver and stopped, so a Moshpit
name resolved and then failed its TLS handshake. To the person who typed
the URL that is not "DNS works", it is "still broken" — which is exactly
how it was reported.

No certificate authority will ever close that gap. The Baseline
Requirements banned issuance for non-IANA names: it stopped in November
2015 and the survivors were revoked by October 2016, and a CA in a root
store that issued for `.rank` would be distrusted for doing it. The rule
is the penalty, so this is not a matter of trying harder.

moshpit-proxy already solved it — verify the origin key against the pin
the registry published, then re-sign with a root generated on this
machine, because restating the result is the only language a stock client
accepts. Nobody had wired it up. This is the wiring, not new crypto.

The one thing it must never do is install a root that could vouch for the
clearnet, so that is checked rather than assumed. A root without a
critical nameConstraints extension permitting our endings is REFUSED, not
warned about — there is no wording of a warning that makes an
unconstrained root in a system store acceptable. moshpit-proxy sets the
constraint today and tests it; this side cannot assume it always will,
because that regression would otherwise land automatically on every
machine that ran `dns enable`.

Reported honestly rather than summarised:

  - NSS and the system store are separate outcomes. NSS is user-level and
    always lands; curl's store needs root. Saying "installed" while curl
    still refuses is what sends someone back to conclude it is broken, so
    without root it does the half it can and names the half it cannot.
  - It never fails `enable`. DNS is switched and verified by the time
    this runs; failing the command over a trust store would roll back
    working resolution to fix a certificate.

`--no-trust` opts out.

This only helps machines it is run on. Nothing shippable makes a
stranger's curl trust a Moshpit name; TronBrowser is the answer for
people who have run nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread test/trust.test.mjs Fixed
@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.

ralyodio and others added 2 commits August 3, 2026 17:21
The gate accepted a root that can vouch for anything, which is the one
thing it exists to refuse.

`requireNameConstraints` searched the whole `openssl x509 -text` output for
`DNS:.<tld>`. That string reads identically under `Permitted:` and under
`Excluded:`, so the check could not tell the two apart -- and RFC 5280
4.2.1.10 says constraints bind only the name *types* they mention. A root
carrying `nameConstraints=critical,excluded;DNS:.hacker` therefore permits
every DNS name on the internet, names the ending we asked about, is marked
critical, and passed:

    requireNameConstraints -> {"ok":true,"why":"constrained to Moshpit endings"}
    trustPlan.ok -> true | steps -> ["nss","system"]

It then went into the system store. So did a root permitting `.evil` while
excluding `.hacker`, and one permitting `.hacker` *and* `.com`.

Parse the extension instead: read Permitted and Excluded as the separate
lists they are, require a permitted DNS subtree to exist at all, require
every claimed ending inside it, and refuse anything else in it.

Also: the paths were root's, not the operator's.

Everything here keys off a home directory -- moshpit-proxy's root under
~/.moshpit, the NSS database the browser reads at ~/.pki/nssdb. But `dns
enable` needs root, and since #274 it escalates itself, so os.homedir()
is /root by the time this runs. It looked for the root in a directory
moshpit-proxy never wrote to and reported "no local root" on a machine
that had one. operatorHome() resolves SUDO_USER/DOAS_USER through the
shell (passwd, so macOS and unusual layouts work), and the NSS store is
chowned back afterwards -- left root-owned, the browser silently loses
the ability to update its own store. Same $HOME-under-sudo trap #272 and
#274 closed.

Also in this commit:

  - tests skip loudly instead of silently. Four used `if (!text) return`
    when openssl was unavailable, passing without asserting anything --
    a green guard that had tested nothing.
  - `--no-trust` is documented, in `dns` usage and in the CLI schema, so
    help renders it. It existed only as a string literal.
  - `verifyStockTls` is wired in rather than exported, tested and never
    called. It runs only when a root was installed and there is a probe
    name, and never fails the command.
  - merges main, so this sits on #274's self-escalation rather than
    predating it.

1348 tests, 1345 pass, 0 fail.

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

ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Pushed fixes for the review findings — 0760468..b1920c1, fast-forward, no history rewritten.

The gate was not constraining anything

requireNameConstraints searched the whole openssl x509 -text output for DNS:.<tld>. That string reads identically under Permitted: and under Excluded: — and RFC 5280 §4.2.1.10 says constraints bind only the name types they mention, so a root that merely excludes .hacker permits every other DNS name on the internet.

It carried a critical Name Constraints extension naming the exact ending asked about, so it passed. Run against the code as it stood:

# nameConstraints=critical,excluded;DNS:.hacker
requireNameConstraints -> {"ok":true,"why":"constrained to Moshpit endings"}
trustPlan.ok -> true | steps -> ["nss","system"]

Straight into the system store. Two more shapes did the same: permitting .evil while excluding .hacker, and permitting .hacker and .com.

Now parsed properly — Permitted and Excluded read as the separate lists they are, a permitted DNS subtree must exist at all, every claimed ending must be inside it, and nothing else may be. Same root against the fix:

{"ok":false,"why":"the root permits no DNS subtree, so every name it does not exclude is allowed — it could vouch for any name"}
trustPlan.ok -> false | refused -> true | steps -> []

The paths were root's, not yours

dns enable needs root and since #274 escalates itself, so os.homedir() is /root by the time applyTrust runs — while moshpit-proxy wrote its root to ~/.moshpit and the browser reads ~/.pki/nssdb. It was looking in a directory moshpit-proxy never writes to and reporting "no local root" on machines that have one.

operatorHome() resolves SUDO_USER/DOAS_USER through the shell so passwd decides (macOS /Users, unusual layouts), and the NSS store is chowned back afterwards — left root-owned, the browser silently loses the ability to update its own store. Same $HOME-under-sudo trap #272 and #274 closed.

Also

  • Merged main, so this sits on feat(dns,upgrade): ask for root per command, and refuse an escalated update #274's self-escalation instead of predating it.
  • Tests skip loudly. Four used if (!text) return when openssl was unavailable — passing without asserting anything. A green guard that had tested nothing.
  • --no-trust is documented in dns usage and the CLI schema, so moshcode help dns renders it. It existed only as a string literal.
  • verifyStockTls is wired in rather than exported, tested and never called. Only runs when a root was installed and a probe name exists; never fails the command.

Note on one fixture

The shared CONSTRAINED fixture permitted .hacker and .rank while its tests asked about hacker alone, which the new "reaches past Moshpit" rule refuses. I narrowed the fixture to match, since production passes the full claimed list. If you intend a root to legitimately permit endings beyond those currently claimed, say so and I'll relax that rule — it's the one judgement call here I'd want your read on.

1348 tests, 1345 pass, 0 fail, 3 skipped.

Comment thread test/trust.test.mjs

test("the root is looked for where moshpit-proxy writes it", () => {
assert.equal(caPath({ home: "/home/x" }), "/home/x/.moshpit/ca/ca.crt");
assert.match(describeCertificateCommand("/tmp/ca.crt").args.join(" "), /-noout -text/);
@ralyodio
ralyodio marked this pull request as ready for review August 3, 2026 17:32
@ralyodio
ralyodio merged commit 9617f81 into main Aug 3, 2026
4 checks passed
@ralyodio ralyodio mentioned this pull request Aug 3, 2026
ralyodio added a commit that referenced this pull request Aug 3, 2026
`dns enable` now installs the local CA as well as the routing, so a
Moshpit name that resolves also survives a TLS handshake (#275). Minor,
not patch: it does something new on a command people already run.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ralyodio
ralyodio deleted the feat/trust-local-ca branch August 3, 2026 17:35
ralyodio added a commit that referenced this pull request Aug 3, 2026
The name-constraints gate refused correctly and then left you there. Both
refusals printed the same line -- "moshcode will not put an unconstrained
root in your trust store" -- with no next step, and for one of them that
sentence was not even true.

They are nothing alike. A root permitting endings other than the ones
claimed today is *stale*, not dangerous: it was generated when a different
set was claimed, and regenerating it costs one command. A root with no
permitted DNS subtree can vouch for any name on the internet.

Collapsing those into one dead end is how a safety gate ends up being
worked around with --no-trust rather than satisfied -- the operator has
no way to tell "this is a real hole" from "this is out of date", so the
opt-out becomes the obvious move.

So each refusal now carries its own remedy, and only one of them has a
way forward:

  STOP the root does not permit rank
       the root is older than the endings claimed now — regenerating it is enough:
         rm -rf /home/anthony/.moshpit/ca && moshpit-proxy   # writes a fresh root
       then re-run `moshcode dns enable`.

  STOP the root permits no DNS subtree, so every name it does not exclude is allowed
       moshcode will not put a root that can vouch for names outside Moshpit
       into your trust store. ... This is not overridable.

The dangerous case is deliberately offered nothing, and a test asserts it
never advertises --no-trust as the fix.

This is the strictness note from #275 answered: the rule stays, because it
is the actual guarantee, but it is no longer a dead end when a root is
simply out of step.

1351 tests, 1348 pass, 0 fail.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 4, 2026
…the registry (#279)

* feat(dns): trust a name on the strength of its pin, and stop blaming 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>

* feat(dns): --trust-all, so every name works without a command per name (#281)

`dns trust <name>` works and does not scale. Someone browsing Moshpit
meets a certificate error on every site they have not personally thought
about, which is indistinguishable from the namespace being broken.

`dns start --trust-all` trusts a name as it resolves: fetch the
certificate it serves, check the key against the pin the registry
published for that name, install it only on a match. Nothing is trusted
on sight — a name with no published pin gets nothing, silently and
forever — so this is registry-backed trust rather than trust-on-first-use.

Three ways the automation could go wrong, none about cryptography:

  - blocking a DNS answer on certificate work. consider() queues and
    returns; the drain runs detached from the query handler.
  - asking once per query rather than once per name. A browser sends A
    and AAAA together and retries, so "on resolve" is a firehose: ten
    lookups of two names is two certificate fetches.
  - retrying a name that will never succeed. A refusal is final for that
    name until restart, or every lookup writes a log line and fails.

Only a name that actually resolved to one of ours is considered: a
forwarded clearnet name is not ours to trust, and NXDOMAIN has no origin
to fetch from. Without root it says so once, rather than failing per name
forever in the query log.

A registry outage is not narrated per name — if the registry is down
every name fails, and saying so each time turns the query log into the
outage. Only refusals and successes are reported.

Fixed while testing: idle() awaited a boolean rather than the in-flight
drain, so it reported a queue as settled while it was still being worked.
A flag can say someone else is draining; it cannot be awaited.

Stacked on #279, which added the per-name command this automates.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ralyodio ralyodio mentioned this pull request Aug 4, 2026
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