Skip to content

moshpit: publish key pins per TLD - #82

Merged
ralyodio merged 1 commit into
masterfrom
feat/tld-pins
Jul 31, 2026
Merged

moshpit: publish key pins per TLD#82
ralyodio merged 1 commit into
masterfrom
feat/tld-pins

Conversation

@ralyodio

Copy link
Copy Markdown
Collaborator

Adds the missing piece between the registry and the two Moshpit clients: a place to publish the keys a name is allowed to present.

Why per-TLD

The registry has never had a per-name record — you claim .eggs, not scrambled.eggs — so there was nowhere for a key to hang. Pins attach to the TLD instead, and a client that resolved scrambled.eggs gets an answer without the registry ever having to know that scrambled exists.

The trade is real and documented in the code: names under one TLD share a key, so there is no isolation between them. The TLD's operator could impersonate any name under it — which they can do regardless, since they own the namespace and decide where its names point.

kind

A tls pin covers a certificate's SubjectPublicKeyInfo (moshpit-proxy); an mtp pin covers an ML-DSA-65 identity (moshpit-transport). Both are SHA-256 over an SPKI, so as strings they're indistinguishable — nothing but that column stops a client being handed the wrong one and failing with no idea why. The same pin can't be published under two kinds.

Omitting kind on lookup is safe rather than merely convenient: a pin of the wrong kind can never match, since an ML-DSA SPKI hash won't equal a presented TLS SPKI hash.

Rotation

Several rows per (tld, kind) on purpose — a key can't rotate without a window where old and new are both published. Withdrawing the last key of a kind is allowed; that's how a compromised key is revoked.

API

GET    /api/moshpit/pins?name=&kind=      public;  400 not a name, 404 no key published
GET    /api/moshpit/tlds/:tld/pins        public
POST   /api/moshpit/tlds/:tld/pins        owner only
DELETE /api/moshpit/tlds/:tld/pins?pin=   owner only

Aliases are followed before pins are read: foo.agentic under an alias to .agent connects to whatever serves foo.agent, so .agent's keys are the ones presented.

Tests

15 new tests against a real SQLite database (libsql takes a file: URL) rather than a stub, because the behaviour worth checking lives in the SQL and the ownership checks. 142/142 repo-wide, tsc --noEmit clean.

One test caught a genuine bug: pinsForName didn't check registered, so example.com parsed as label example under TLD com and got answered "no key published" instead of "not a Moshpit name" — two things clients cache differently and act on differently.

Not in this PR

  • No UI. app/pit/ has only page.tsx; publishing a pin is API-only for now.
  • No origin/target field, so the gateway's SNI→origin map still has no source. Separate concern from keys.

🤖 Generated with Claude Code

The registry has never had a per-name record — you claim `.eggs`, not
`scrambled.eggs` — so there was nowhere for a key to hang. Pins therefore
attach to the TLD: `.eggs` publishes the keys that names under it may
present, and a client that resolved `scrambled.eggs` knows what to expect
without the registry ever having to know that `scrambled` exists.

The trade is real and is written down in the code: names under one TLD share
a key, so there is no isolation between them. The TLD's operator could
impersonate any name under it — which they can do regardless, since they own
the namespace and decide where its names point.

`kind` separates the transports. A `tls` pin covers a certificate's
SubjectPublicKeyInfo; an `mtp` pin covers an ML-DSA-65 identity. Both are
SHA-256 over an SPKI, so as strings they are indistinguishable, and nothing
but that column stops a client being handed the wrong one and failing with no
idea why. The same pin cannot be published under two kinds.

Several rows per (tld, kind) on purpose: a key cannot rotate without a window
where the old and new are both published. Withdrawing the last key of a kind
is allowed — that is how a compromised key is revoked, and refusing it on the
grounds that it breaks connections would be refusing the point.

  GET    /api/moshpit/pins?name=&kind=      public; 400 not a name, 404 no key
  GET    /api/moshpit/tlds/:tld/pins        public
  POST   /api/moshpit/tlds/:tld/pins        owner only
  DELETE /api/moshpit/tlds/:tld/pins?pin=   owner only

Aliases are followed before pins are read: `foo.agentic` under an alias to
`.agent` connects to whatever serves `foo.agent`, so `.agent`'s keys are the
ones that will be presented.

15 tests against a real SQLite database rather than a stub, since the
behaviour worth checking is in the SQL and the ownership checks. One of them
caught a genuine bug: `pinsForName` did not check `registered`, so
`example.com` parsed as label `example` under TLD `com` and was answered
"no key published" instead of "not a Moshpit name" — two things clients cache
differently and act on differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit 2de9ad6 into master Jul 31, 2026
2 checks passed
@ralyodio
ralyodio deleted the feat/tld-pins branch July 31, 2026 08:04
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