feat: libp2p-agnostic /v2 registration api#92
Draft
lidel wants to merge 16 commits into
Draft
Conversation
Add an opt-in /v2 registration path that authenticates with HTTP Message Signatures (RFC 9421) plus Digest Fields (RFC 9530) instead of the libp2p PeerID-auth handshake, so a client needs only an Ed25519 signer rather than a libp2p HTTP stack. v1 is unchanged and stays the default. - internal/httpsig: fixed-profile RFC 9421 signer/verifier, RFC 9530 Content-Digest, and did:key (Ed25519) encode/decode, shared by client and server; golden signature-base and did:key round-trip tests - acme: POST /v2/_acme-challenge, GET /v2/health, and a GET /v2 profile mounted beside v1 on the same mux and datastore; identity is derived from the signature keyid via peer.IDFromPublicKey, the real-node check reuses the existing dialback, and the DNS-01 write matches v1 exactly - client: SendChallengeV2 (single signed POST, no cookie jar) and WithRegistrationAPIVersion (v1 default, v2, or auto with v1 fallback) The v2 wire surface stays libp2p-agnostic: requests and the success response carry a did:key, never a raw peerid.
The dialback dials addresses an authenticated client supplies, so it must never reach internal, link-local, or metadata targets. Add a shared destination-IP vetter and pin the vetted IPs through a libp2p connection gater so neither TCP nor QUIC can be steered off them. - vetDestIP rejects non-global-unicast destinations: loopback, unspecified, 0.0.0.0/8, RFC1918, CGNAT, link-local, ULA, multicast, benchmarking, Class E, and the IPv4-embedding v6 ranges (IPv4-mapped, IPv4-compatible, NAT64, 6to4, Teredo), unmapping v4-in-v6 first - resolveAndVet resolves /dns* to concrete IPs, drops relay and non-public addresses, and bounds dial fan-out after resolution; the probe dials only concrete pinned IPs, closing the DNS-rebinding gap - the probe now runs under a timeout, caps the address count, reads the agent version from the identify event instead of a racy peerstore get, denylist-checks resolved IPs (which the literal-only check missed), and returns a generic reachability error so it cannot be used to port-scan - new registration-domain option allow-private-addresses (default false) turns vetting off for tests and private deployments Both v1 and v2 registration share the hardened path.
Protect /v2 from replay of a captured signed request and from registration floods, and stop trusting a spoofable X-Forwarded-For. - a nonce store records each (peer, nonce) in the shared datastore with a TTL longer than the signature validity window, so a replay within that window is rejected; reservation is atomic per instance and fails closed - a per-source-IP token bucket runs before the signature verify, returns Retry-After on 429, and evicts idle buckets - clientIPs no longer trusts a leftmost X-Forwarded-For, which any client can forge; it honors the direct address plus an operator-named trusted header via the new client-ip-header option - startup asserts the nonce TTL outlives the max signature lifetime plus clock skew
Let a node prove key control over a plain HTTP(S) endpoint instead of a libp2p dialback, so a registrant needs no libp2p on either leg. - internal/httpsig: SignOwnership/VerifyOwnership build and check a static RFC 9421 proof binding the key to a canonical origin, verified under the registration keyid (never the proof's own named key), with a bounded validity window - client: OwnershipProofHandler serves the cached, offline-signable proof at /.well-known/p2p-forge/<did:key>, and CanonicalOrigin parses an origin-only URL - acme: verifyReachable picks http-ownership for http(s) addresses and the libp2p dialback otherwise; the fetch resolves and pins the endpoint IP, refuses non-public targets and non-80/443 ports, disables redirects, caps the body, verifies WebPKI when a valid cert exists and falls back to the pinned-IP plus signature path when it does not, and bounds all reachability work with one deadline
Add docs/registration-v2.md as the normative reference for the RFC 9421 /v2 API, using RFC 2119 requirements language: endpoints, the signing profile with worked signature bases, the http-ownership proof, error shapes, and operator config. http-ownership is the SHOULD path; libp2p-dialback is OPTIONAL, so a forge can drop the libp2p stack. Add docs/key-types.md as the companion on adding key types (the post-quantum ML-DSA standards path, and why a large public key stays out of the DNS label). Point the README at the spec, and at the libp2p AutoTLS spec for /v1, instead of restating the v1 request inline. Record the new options and the X-Forwarded-For fix in the changelog.
The probe subscribed to the libp2p identify event only to label a bounded-cardinality metric more accurately. Drop the subscription and the helper; label the probe from the HTTP User-Agent, which the handler already passes in. No security or behavior change beyond the metric label.
Replace the bespoke RFC 9421 response-signature ownership proof with a standard EdDSA JWT (golang-jwt/jwt/v5). The node serves a compact token with an `origin` claim plus `iat`/`exp` at the well-known path; the forge verifies it under the registration key (never the token's own `kid`) and checks the origin and expiry. This drops internal/httpsig/ownership.go for a ubiquitous standard that fits the did:key ecosystem, and keeps the SSRF-safe fetch unchanged.
Replace the hand-rolled RFC 9421 signer and verifier (~500 lines of canonicalization) with github.com/yaronf/httpsign, a maintained library tested against the spec vectors. internal/httpsig now pins only the profile (covered components, parameters, clock bounds) and the did:key keyid: the client signs with NewEd25519Signer, and the server reads the keyid via RequestDetails, resolves the key from the did:key, and verifies. Content-Digest is generated and validated by the library. The on-the-wire profile (components, parameters, tag) is unchanged.
Drop the in-memory per-source-IP token bucket and its handler gate. Request rate limiting belongs on the fronting reverse proxy, CDN, or load balancer, which libp2p.direct already runs behind; a per-instance limiter duplicates that and multiplies by the backend count. The nonce store (replay protection, which a proxy cannot do) and the denylist stay. Docs now say rate limiting is the operator's proxy responsibility.
The verifier library treats expires as optional and puts no bound on expires-created, so the server enforced less than the spec promised. - require created and expires, bound expires-created by the 5-minute MaxSignatureLifetime, raise the nonce minimum to 22 base64url chars - split profile-grammar failures (400 malformed-signature) from authentication failures (401 signature-invalid); a body that contradicts its own digest is malformed, so 400 - drop the never-populated verification.addr response field - enumerate every problem type fragment in the spec errors table, point the type URIs at the docs, and fix the 104-bit example nonce - pin the 401 side with expired-signature and wrong-keyid tests
An IPv6 literal produced an unbracketed origin
("https://2001:db8::1:443") that failed URL parsing when the forge
fetched the ownership proof, so IPv6-literal registrations always
failed verification.
- bracket IPv6 hosts via net.JoinHostPort and collapse IP literals to
one canonical textual form, on signer and verifier alike
- reject zoned literals (fe80::1%eth0): host-local, never publicly
verifiable, and the "%" is not URL-safe
- end-to-end proof test on [::1] pinning the bracketed form
The optional shared-secret gate compared tokens with plain string equality, a timing oracle. Hash both sides and compare with crypto/subtle in the v1 and v2 handlers, and cover the refusal path, which no test exercised.
Remove a duplicate empty "### Fixed" heading under Unreleased and reword a v2 e2e comment that described the history instead of the final state.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #92 +/- ##
==========================================
- Coverage 68.70% 67.38% -1.33%
==========================================
Files 21 32 +11
Lines 1713 2379 +666
==========================================
+ Hits 1177 1603 +426
- Misses 416 578 +162
- Partials 120 198 +78 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The https proof fetch tried WebPKI verification first and retried without it on a cert error. The first attempt had no observable effect: a valid cert never gated acceptance (the JWT signature does), an invalid one never caused rejection, and the result was recorded nowhere, so the common case (a node registering because it has no CA cert yet) paid two TLS handshakes for nothing. Fetch once without transport verification: authenticity comes from the Ed25519 proof signature, host binding from the pinned, vetted IP, and redirects stay refused. Spec operator note updated to match.
The "created older than 7 minutes" rule could never reject anything on its own: expires must be present, at most created+300s, and not passed, so any created old enough to trip the rule already comes with an expired signature. The 2-minute skew grace was just as dead: a slow clock shifts created and expires by the same amount. Delete MaxClockSkew and spell out the real policy in the spec: created at most 30s in the future, expires not passed, and expires-created at most 300s. The verifier keeps a created age bound only because the library needs one when verifyCreated is on.
The endpoint served a JSON summary of the signing profile, but the
spec never defined its fields, half the values were prose
("did:key (Ed25519)"), and nothing consumed it: the client detects
v2 support by POSTing and treating 404/405 as unsupported.
A discovery endpoint can return once there is more than one key
type to discover, with a defined schema and machine-readable
values. key-types.md now points at the malformed-signature error
instead of descriptor-based discovery.
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.
Warning
Very early prototype, and it is not yet decided whether this ships. Opening it for early feedback: a sanity check on the approach, and pointers on direction or gaps I may have missed. Treat the design as unsettled and expect rough edges. Any feedback appreciated.
cc @aschmahmann @achingbrain – no need to read code, just read
.mdfilesProblem
Getting a
*.libp2p.directcert through/v1means running a full libp2p client: the PeerID-auth handshake to authenticate, and a libp2p dialback to prove reachability. Any HTTP-only client that just wants a cert for its peer key is shut out, and the dialback could be pointed at internal addresses.Fix
/v2is an opt-in addition that runs next to/v1, which stays the default and is unchanged. A client authenticates with an HTTP Message Signature1 over an Ed25519did:key2, so any HTTP-only client can register without a libp2p stack (prototyped in ipfs/kubo#11333). It proves control of a real endpoint one of two ways: serve an offline-signable JWT3 at/.well-known/p2p-forge/<did:key>(no libp2p), or answer a libp2p dial. The dialback now vets and pins destination IPs to guard against SSRF4 and DNS rebinding5, the denylist ignores a forgedX-Forwarded-For, and a single-use nonce blocks replay.Nothing changes for existing users: the
/v1client and server keep working as before, and clients stay on/v1unless they opt in withWithRegistrationAPIVersion(v2, orautoto tryv2and fall back tov1). The full/v2wire spec is the best place to start a review.Constraints
/v2is Ed25519-only for now, until the Post-quantum key support in Kubo ipfs/kubo#11281 situation clarifies; other libp2p key types stay on/v1.http-ownershipproof binds scheme, host, and port; the forge pins the resolved public IP, refuses non-public targets, and follows no redirects.libp2p-dialbackis optional: a forge can shiphttp-ownershipalone and drop the libp2p dependency.Open Questions
/.well-known/p2p-forge/<did:key>today. Should it be/.well-known/autotls/instead, so the well-known name matches the AutoTLS feature rather than this implementation?/v2until a spec pins the DER-to-r || sconversion, instead of listing them as possible later?Footnotes
RFC 9421 (HTTP Message Signatures): a signature over a fixed set of request parts, producible by any RFC 9421 tool. ↩
W3C
did:key: a self-describing public-key identifier, so the wire format stays libp2p-agnostic. ↩RFC 7519 (JWT): a small signed token, so the node can sign it offline and a CDN can serve it. ↩
SSRF: the forge fetches an address the caller picks, so an unguarded fetch could hit a cloud metadata service or internal host. Real here, because fetching a caller-supplied URL is the whole point of the check. ↩
DNS rebinding: the caller's hostname answers public when the forge checks it, then internal when it connects. Pinning to the vetted IP closes the gap. ↩