Skip to content

feat(moq-tokio): identify peers by certificate fingerprint - #2944

Open
kixelated wants to merge 2 commits into
devfrom
claude/mdns-p2p-networking-al85rx-tls
Open

feat(moq-tokio): identify peers by certificate fingerprint#2944
kixelated wants to merge 2 commits into
devfrom
claude/mdns-p2p-networking-al85rx-tls

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

The TLS groundwork for letting a LAN mesh peer know which peer just connected to it. Additive, no behavior change to any existing configuration. First of three; the mesh changes that use it follow.

The problem

A listener could authenticate an inbound client certificate against a CA (Listen::root), or not ask for one at all. A peer mesh has neither: there is no authority to issue the certificates, and membership is a set that changes while the process runs as peers are discovered and expire.

So today Lan::authorized in rs/moq-cli/src/cluster.rs compares the request path against this listener's own credential, which every peer that dials it presents identically. The acceptor learns that a member connected and nothing more. That is what blocks both-sides-dialing, which is in turn what would make the mesh survive a one-sided firewall (documented as a limitation in #2942).

What this adds

tls::Identity is a self-signed certificate and key held in memory, served via Listen::identity and presented via Connect::identity. One key covers both roles deliberately: a peer is then the same principal whether it dialed or accepted, so a single published fingerprint identifies it in both directions and the mDNS advert needs no second TXT key.

tls::Peers is the accept-side counterpart to Connect::fingerprint: an allowlist of client fingerprints, read per handshake rather than fixed when the listener is built, because a mesh learns its members as it goes. PeerVerifier enforces it.

Two deliberate choices there. Pinning is checked inside rustls rather than handed to the application, so an unknown peer fails the handshake instead of arriving unnamed for the caller to remember to filter. And unlike the CA-rooted verifier, client_auth_mandatory is true: a listener that pins peers wants to know who connected, so an anonymous client is refused rather than accepted with no identity.

PeerIdentity::fingerprint then names the peer that got in, matching what that peer published as its Identity::fingerprint.

Combining peers with root is refused (Error::PeersWithRoots), for the same reason Connect::fingerprint already refuses to combine with roots: pinning bypasses the chain, so one of the two would be silently ignored. Same for an Identity alongside a cert/key pair, since only one client certificate can go on the wire.

Refactors that fell out

  • The three server backends each decided client auth for themselves with the same config.tls.root.is_empty() branch, so a new mode would have had to land in each of them. Listen::client_auth is now the single place that decides, and quinn/noq defer to it.
  • Certificate generation was a ServeCerts method that Identity also needed, so it is now a free function both call rather than a copy.

quiche

Refused rather than silently ignored. quiche takes raw DER and fixes its client-auth roots when the listener is built, so it can honor neither in-memory mode; configuring either against it is Error::MemoryUnsupported.

Bug caught while testing

load_certs rejected a listener as having no certificate source when only identity was set, since the guard counted cert and generate only. identity_serves_the_fingerprint_it_presents failed on it; fixed and the test now covers it.

Testing

7 new tests in rs/moq-tokio/src/tls.rs, covering: one identity serving the fingerprint it presents, the two conflict rejections, client auth staying off until configured, live add/remove of a pinned peer (including that another peer's certificate is not interchangeable), the fingerprint an accepted session reports, and malformed fingerprints being rejected on the way in.

cargo test -p moq-tokio --all-features --lib gives 215 passed / 6 failed; the same 6 fail identically on unmodified dev in this container, all EAFNOSUPPORT when a test binds a UDP socket (no IPv6 here). cargo clippy --all-features --all-targets and cargo fmt --check are clean apart from the crate::connect unused-import warning already on dev. just/nix aren't available in this environment, so the recipes themselves weren't run.

Branch targeting

dev, not main: rs/moq-tokio only exists here (main still has it as rs/moq-native), so there is nowhere on main for this to land even though it is additive.

Cross-package sync

Nothing in the table applies. No wire format, no moq-ffi, no CLI surface: both new fields are #[arg(skip)] / #[serde(skip)], settable only from library code, so no flag or TOML key changes and no doc page describes them. doc/lib/rs/crate/moq-tokio.md doesn't cover mTLS at all.

(Written by Claude Opus 5)


Generated by Claude Code

A listener could authenticate a client certificate against a CA, or not at
all. A peer mesh has neither: there is no authority to issue certificates,
and membership is a set that changes while the process runs. So a mesh
listener today learns that *a* member connected and nothing more, which is
not enough to tell which peer it is talking to.

Three pieces, all additive:

`Identity` is a self-signed certificate and key held in memory, served via
`Listen::identity` and presented via `Connect::identity`. One key covers
both roles on purpose: a peer is then the same principal whether it dialed
or accepted, so one published fingerprint identifies it in both directions.

`Peers` is the accept-side counterpart to `Connect::fingerprint`: an
allowlist of client fingerprints, read per handshake rather than fixed when
the listener is built, because a mesh learns its members as it discovers
them. `PeerVerifier` enforces it, and unlike the CA-rooted verifier it makes
client auth mandatory, since a listener that pins peers wants to know who
connected. A peer that is not in the set fails the handshake rather than
arriving unnamed for the application to filter.

`PeerIdentity::fingerprint` then names the peer that got in, matching what
that peer published.

Two bits of tidying that fell out. The three server backends each decided
client auth for themselves with the same `root.is_empty()` branch, so a new
mode would have had to land in each; `Listen::client_auth` is now the one
place that decides, and quinn/noq defer to it. Certificate generation was a
`ServeCerts` method that `Identity` also needed, so it is a free function
both call.

quiche is refused rather than silently ignored: it takes raw DER and fixes
its client-auth roots when the listener is built, so it can honor neither
in-memory mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDHxYmtSzej5eaz3NUAfhQ
@kixelated
kixelated marked this pull request as ready for review August 19, 2026 21:48
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

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