Skip to content

feat: libp2p-agnostic /v2 registration api#92

Draft
lidel wants to merge 16 commits into
mainfrom
feat/v2-registration-api
Draft

feat: libp2p-agnostic /v2 registration api#92
lidel wants to merge 16 commits into
mainfrom
feat/v2-registration-api

Conversation

@lidel

@lidel lidel commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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 .md files

Problem

Getting a *.libp2p.direct cert through /v1 means 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

/v2 is 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 Ed25519 did: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 forged X-Forwarded-For, and a single-use nonce blocks replay.

Nothing changes for existing users: the /v1 client and server keep working as before, and clients stay on /v1 unless they opt in with WithRegistrationAPIVersion (v2, or auto to try v2 and fall back to v1). The full /v2 wire spec is the best place to start a review.

Constraints

  • /v2 is Ed25519-only for now, until the Post-quantum key support in Kubo ipfs/kubo#11281 situation clarifies; other libp2p key types stay on /v1.
  • Build on standard HTTP and published RFCs where possible; invent protocol only to close a real gap or bridge abstractions.
  • The forge does not rate-limit. That belongs on the fronting proxy, CDN, or load balancer.
  • The http-ownership proof binds scheme, host, and port; the forge pins the resolved public IP, refuses non-public targets, and follows no redirects.
  • libp2p-dialback is optional: a forge can ship http-ownership alone and drop the libp2p dependency.

Open Questions

  • The proof path is /.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?
  • ECDSA and secp256k1 sign in a DER encoding that a generic RFC 9421 verifier rejects. Should the spec state plainly that these keys are incompatible with /v2 until a spec pins the DER-to-r || s conversion, instead of listing them as possible later?
  • Post-quantum is designed for but not enabled: the profile can add key types (ML-DSA) without a breaking change, once go-libp2p can produce and verify the keys (Post-quantum key support in Kubo ipfs/kubo#11281).

Footnotes

  1. RFC 9421 (HTTP Message Signatures): a signature over a fixed set of request parts, producible by any RFC 9421 tool.

  2. W3C did:key: a self-describing public-key identifier, so the wire format stays libp2p-agnostic.

  3. RFC 7519 (JWT): a small signed token, so the node can sign it offline and a CDN can serve it.

  4. 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.

  5. 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.

lidel added 13 commits July 15, 2026 12:42
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.
@lidel
lidel requested review from achingbrain and aschmahmann July 17, 2026 18:26
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.68843% with 238 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.38%. Comparing base (d629320) to head (7d48e40).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
acme/dialguard.go 65.00% 30 Missing and 12 partials ⚠️
acme/ownership.go 59.22% 27 Missing and 15 partials ⚠️
client/challenge_v2.go 58.69% 21 Missing and 17 partials ⚠️
acme/writer_v2.go 71.73% 18 Missing and 8 partials ⚠️
client/ownership.go 69.56% 12 Missing and 9 partials ⚠️
client/acme.go 39.39% 18 Missing and 2 partials ⚠️
acme/verify_v2.go 66.66% 7 Missing and 7 partials ⚠️
acme/setup.go 60.00% 6 Missing and 2 partials ⚠️
internal/ownership/ownership.go 75.75% 4 Missing and 4 partials ⚠️
acme/antiabuse.go 70.00% 3 Missing and 3 partials ⚠️
... and 3 more
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

lidel added 3 commits July 24, 2026 14:09
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.
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