feat(moq-tokio): identify peers by certificate fingerprint - #2944
Open
kixelated wants to merge 2 commits into
Open
feat(moq-tokio): identify peers by certificate fingerprint#2944kixelated wants to merge 2 commits into
kixelated wants to merge 2 commits into
Conversation
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
marked this pull request as ready for review
August 19, 2026 21:48
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::authorizedinrs/moq-cli/src/cluster.rscompares 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::Identityis a self-signed certificate and key held in memory, served viaListen::identityand presented viaConnect::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::Peersis the accept-side counterpart toConnect::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.PeerVerifierenforces 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_mandatoryis true: a listener that pins peers wants to know who connected, so an anonymous client is refused rather than accepted with no identity.PeerIdentity::fingerprintthen names the peer that got in, matching what that peer published as itsIdentity::fingerprint.Combining
peerswithrootis refused (Error::PeersWithRoots), for the same reasonConnect::fingerprintalready refuses to combine with roots: pinning bypasses the chain, so one of the two would be silently ignored. Same for anIdentityalongside acert/keypair, since only one client certificate can go on the wire.Refactors that fell out
config.tls.root.is_empty()branch, so a new mode would have had to land in each of them.Listen::client_authis now the single place that decides, and quinn/noq defer to it.ServeCertsmethod thatIdentityalso 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_certsrejected a listener as having no certificate source when onlyidentitywas set, since the guard countedcertandgenerateonly.identity_serves_the_fingerprint_it_presentsfailed 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 --libgives 215 passed / 6 failed; the same 6 fail identically on unmodifieddevin this container, allEAFNOSUPPORTwhen a test binds a UDP socket (no IPv6 here).cargo clippy --all-features --all-targetsandcargo fmt --checkare clean apart from thecrate::connectunused-import warning already ondev.just/nixaren't available in this environment, so the recipes themselves weren't run.Branch targeting
dev, notmain:rs/moq-tokioonly exists here (mainstill has it asrs/moq-native), so there is nowhere onmainfor 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.mddoesn't cover mTLS at all.(Written by Claude Opus 5)
Generated by Claude Code