feat: libp2p-agnostic /v2 registration api - #92
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% 68.90% +0.20%
==========================================
Files 21 32 +11
Lines 1713 2422 +709
==========================================
+ Hits 1177 1669 +492
- Misses 416 557 +141
- Partials 120 196 +76 ☔ 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.
A replayed v2 request cannot do anything new: the signature binds the body, so a replay re-verifies the same addresses and re-writes the same TXT value. An attacker gains more by minting a free Ed25519 key and sending fresh requests, and the defenses for that (destination vetting, timeouts, rate limiting on the fronting proxy) cover replays too. The store also could not keep its promise. reserve() was a non-atomic Has+Put behind one process-wide mutex: every registration on an instance queued behind two datastore round trips, while two instances (or one, on eventually-consistent DynamoDB reads) could still both accept the same nonce. When the store was nil, the check silently vanished. Replay is now bounded by the expires window (at most 300s plus 30s of forward drift). The nonce stays a required signature parameter: it keeps every signature unique, and a server MAY enforce single-use later without any client change. The 409 and nonce-store-error responses are gone from the spec and the code.
The optional Forge-Authorization gate is a p2p-forge extra for limited rollouts and test instances, not protocol, so the v2 spec no longer defines it: the 403 forbidden row is gone and the error table notes that access control outside the spec may add statuses. The code keeps the gate. Its godoc now says exactly what it is, and its 403 response uses the generic about:blank problem type instead of pointing at a spec anchor that defines nothing.
The spec promised a closed grammar the verifier did not have: the library checks that required components are covered, but accepts extra components, any order, any label, unknown parameters, any alg value, and any nonce shape. A request the spec calls invalid would register fine, and a second implementation written from the spec would diverge from this one. The server now rejects, before any cryptography runs: - more than one Signature-Input or Signature header, more than one signature, or a label other than sig1 - covered components that are not exactly the profile list, in order, with no per-component parameters - an alg other than ed25519 (the key in keyid decides the algorithm) and any signature parameter outside the profile - a nonce that is not unpadded base64url of at least 128 bits - a Content-Type other than application/json The client signs without alg, matching the spec example. The spec now states each rule where it was silent, and the error table lists the new rejections.
Gaps between what the spec promises and what ran, closed on the strict side, plus one restriction dropped as wrong: - the JWT now carries typ "p2p-forge-ownership+jwt" and the verifier requires it (RFC 8725 explicit typing), so no other JWT signed by the same identity key can pass as a proof - the proof endpoint may listen on any port: requiring 80 or 443 excluded exactly the NATed nodes AutoTLS exists for (UPnP or router-forwarded ports), while the libp2p dialback already accepted arbitrary ports. The public IP stays vetted, pinned, and denylistable; the port never mattered for abuse control - Sign counts exp from the backdated iat, so exp-iat never exceeds the requested ttl, and Verify enforces the 14-day cap exactly instead of quietly widening it - the fetch pins one vetted resolved IP per address family and tries each, so a single stale AAAA no longer sinks a proof that a working A record could serve - address partitioning treats schemes case-insensitively, so HTTPS://gw.example is an http URL, not a broken multiaddr The spec now defines the origin string as an algorithm with examples (citing RFC 5952 and RFC 6454, stating the deviation), documents the clock-skew allowances, shows http alongside https in the proof URL template, and notes the well-known suffix may be registered with IANA later if the API sees adoption. The registration key is decoded once per request instead of once per submitted URL.
The probe timeout only covered the dial: /dns* resolution ran unbounded, and the v1 handler calls testAddresses with no timeout of its own, so up to 32 resolutions against a tarpit nameserver could hold a request goroutine (and its libp2p host) for minutes. The timeout now wraps the whole probe, resolution included, which also closes the v1 gap without changing v1 wire behavior. The address caps now bound work instead of failing registrations: addresses past the cap (32 multiaddrs, 8 http URLs) are ignored, so a client sending 40 addresses with 3 dialable ones registers instead of being rejected outright. Both caps are now in the spec. vetDestIP gains three ranges the list missed: 192.0.0.0/24 (IETF protocol assignments, incl. DS-Lite), 192.88.99.0/24 (deprecated 6to4 relay anycast), and 100::/64 (discard-only). allow-private-addresses docs (README, spec, field godoc) now list everything the flag disables, and the spec shows the real Corefile syntax (a registration-domain argument, not a standalone option).
A denylisted caller used to pay for a full signature verification (and, on v1, reach the dialback) before the denylist looked at its IP, and the submitted-address check ran in the same late step. Split the denylist check so the client IP is tested right after the auth gate, before any crypto or datastore work; the submitted addresses are still checked once the body is parsed. The trusted client-IP header now accepts "ip:port", not just a bare IP, since some proxies append the source port. An unparseable value is logged instead of silently dropped, which otherwise leaves the denylist quietly matching the proxy's own address. Configuring client-ip-header as X-Forwarded-For is refused at startup: a client can prepend to it and dodge the denylist.
The v1 and v2 senders carried separate copies of the same request decoration and error rendering, and the copies had already drifted: v1 never closed the response body and read the error body unbounded, while v2 did both correctly. Pull the shared parts into decorateForgeRequest and renderChallengeError, so v1 now closes the body and bounds the error read like v2. isEd25519 checks the key's Type() instead of asserting a concrete Go type, so a wrapped or delegated Ed25519 key is recognized instead of being routed to v1. Tests pin the v2 status contract the auto fallback relies on: 404 and 405 mean unsupported, 422 does not.
The version selection and auto-mode fallback lived inline in Present, tangled with the libp2p host and HTTP calls, so nothing tested that auto falls back only when the forge lacks a v2 endpoint and not on a verification failure. Pull the rule into a register method taking the two send funcs; behavior is unchanged. Document the dialback assumption on WithRegistrationAPIVersion: in the certmagic flow the node advertises libp2p addresses, so v2 and auto are verified by the forge's dialback. A forge offering only the http-ownership proof is reached through SendChallengeV2 with an http address, not this option.
The success body had two rough edges: the retention duration was named ttl but read like a DNS TTL (the record's real TTL is 10s), and verification wrapped a single string in an object "for future fields". Rename it expiresIn (seconds until the stored value expires), drive it and the PutWithTTL call from one challengeTTL constant, and flatten verification to the string it always was. The whole body is informational; the shipped client reads none of it. The problem type URIs pointed at doc fragments (#malformed-body and the rest) that GitHub never generated, since the errors were table rows, not headings, so every link landed at the top of the page. Add an anchor per slug so each type URI resolves to its own row.
Several claims in key-types.md were wrong or overstated, and the support status was buried in prose: - IANA does not list ml-dsa-44/65/87. Those names come from C2SP httpsig-pq v1.0.0, which requests their registration; the IANA HTTP Message Signature Algorithms registry holds only the six RFC 9421 algorithms. Fixed the sentence and the table. - Both tables gain a Status column: Ed25519 is Supported, every other classical and post-quantum type is Unsupported, so a reader sees at a glance what works instead of parsing notes. - ECDSA needs both a compressed-point key conversion and a DER-to-r||s signature conversion; secp256k1 has no registered algorithm; RSA is compatible but not enabled. The notes say what each would need before it could be turned on. - All the multicodec code points, 0xed included, are registry status draft; the text no longer implies the classical rows are firmer than the post-quantum ones. - Cite W3C Controlled Identifiers 1.0 (a Recommendation) as the normative source for the Ed25519 did:key encoding.
allow-private-addresses is an argument on the registration-domain line, not a standalone block option, and it disables all reachability safeguards, not just IP vetting. Note that client-ip-header refuses X-Forwarded-For.
aschmahmann
left a comment
There was a problem hiding this comment.
Thanks @lidel for taking a stab here. Left some comments (hopefully that make things easier), lmk what you think
| | `expires` | Unix seconds when the signature stops being valid. `expires - created` MUST be `<= 300`. | | ||
| | `nonce` | At least 128 random bits, unpadded base64url, fresh for every signature. | | ||
| | `keyid` | The `did:key` above. | | ||
| | `tag` | `p2p-forge-reg`. | |
There was a problem hiding this comment.
Is p2p-forge the name we want to use here and in the .well-known, etc.? Or should we use autotls or a different name (given we're not the ones giving the TLS certs, it's just what this as a feature is most known for enabling 😅)
There was a problem hiding this comment.
Agreed, and renamed in c00cd43. Anything another implementation has to match now says autotls: the well-known path is /.well-known/autotls/, and the signature tags are autotls-reg and autotls-ownership. p2p-forge is left as the name of this client/server implementation, not of the "protocol". + AutoTLS is better SEO-wise.
| `registration.libp2p.direct`). `@target-uri` and `@scheme` are deliberately not | ||
| covered, because a TLS-terminating load balancer rewrites the scheme the backend | ||
| sees. A request MUST NOT carry a query string, and the server MUST reject one, so | ||
| `@query` is not covered either. |
There was a problem hiding this comment.
Is this necessarily the case? For example:
- Even if there's a TLS terminating load-balancer wouldn't it be simple enough to assert HTTPS was used if the server only supports that, and the load-balancer should reject any non-HTTP connections anyway?
- Does the server care / complain if the client passes query parameters that are ignored?
This section is probably fine either way since:
- target-uri + scheme, we're just not checking it but it doesn't seem a major security concern
- query: We could allow it to be covered so the signature covers the "there is no query parameter" but I suppose not strictly necessary
There was a problem hiding this comment.
Kept as is, but here is the reasoning, and the doc now states it in the same place.
On scheme: a deployment can require HTTPS at the edge, but the signature is verified by the backend, which behind a TLS-terminating load balancer sees http no matter what the client used. Covering @scheme would make a correct client fail on the forge's own setup, and it protects nothing the edge is not already enforcing.
On query: the server rejects any request with a query string outright, with a 400 unexpected-query. That is stricter than covering @query and needs no signature support, so a stray parameter cannot be silently ignored.
|
|
||
| The `Signature-Input` and `Signature` headers MUST each appear exactly once and | ||
| carry exactly one signature, labeled `sig1`; the server rejects any other label | ||
| and any additional signature: |
There was a problem hiding this comment.
Does it strictly matter if other signatures are appended as long as everyone knows the server is only going to look at sig1?
There was a problem hiding this comment.
It does not, and the spec and the server were changed to say so in 68be023. Extra signatures under other labels are allowed and ignored; the server verifies sig1 and rejects only when sig1 is missing or bad.
I think this relaxation is safe, and improves interop: rejecting the whole request would have broken any client behind a proxy or tool that adds its own signature, for no gain.
| A registration MUST prove control of at least one address in the body. Two | ||
| verification modes exist. A server MUST support at least one; it SHOULD support | ||
| http-ownership, the libp2p-free path, and it MAY support libp2p-dialback. An | ||
| implementation MAY omit the dialback entirely to avoid a dependency on the libp2p | ||
| stack. |
There was a problem hiding this comment.
Related to this is there any reason to support multiaddrs on the v2 endpoint if in practice we assume any multiaddr usage will be for libp2p addresses which can use v1?
There was a problem hiding this comment.
No reason, and they are gone as of 1abfa56. Now, /v2 takes plain http(s) origins, does no libp2p dialback, and the client side imports no go-libp2p at all.
I realized its more confusing than useful, and brings too much baggage. The split is now clean: /v1 proves reachability over libp2p, /v2 proves it over HTTP-only.
| The forge bounds the work per registration: it considers at most the first 8 | ||
| `http(s)` addresses and at most the first 32 multiaddrs (relay addresses are | ||
| skipped without counting), and ignores the rest. Clients SHOULD lead with the | ||
| addresses most likely to verify. |
There was a problem hiding this comment.
In the HTTP(S) case is it likely to need more than even a single address? I'm having trouble thinking of plausible examples, but may just be a creativity issue on my end 😅.
There was a problem hiding this comment.
Not a creativity problem, one is the normal case, accepting list is just an interop precaution. The multiaddr list is gone in 1abfa56 and the body now carries origins, a list of http(s) origins.
It stays a list for the same reason /v1 took several addresses: the forge tries them in order and stops at the first that proves ownership, so a node with a separate IPv4 and IPv6 hostname, or one mid-DNS-move, does not fail registration because the forge picked the one path it could not reach. The list is capped at 4, the rest ignored, and clients are told to lead with the origin most likely to verify.
| keys that run nothing. It does not scope the cert. | ||
|
|
||
| The `<peerid>` never travels on the wire in `/v2`; requests and the success | ||
| response carry a `did:key` instead. The base36 peerid appears only in the DNS |
There was a problem hiding this comment.
This, combined with the hints at the end and in the key-types document, about future (but currently unsupported) keys seems like it could potentially be better. Some thoughts:
- With this scheme HTTP-only implementers still need to understand the libp2p ed25519 protobuf format in order to create identifiers. Not the end of the world, but not ideal.
- Waiting on libp2p key-type support to handle new key types seems like a pretty bad coupling decision that would be good to avoid
Given this would it make sense to do one of:
- Use the CID-like format
<0x01><public-key-type><multihash of public key>where either we say to always use a hash (e.g. standard sha256) or to do the libp2p-like approach of "if number of bytes <= X use identity multihash, otherwise use sha256"?- Could also remove the
0x01as unnecessary, but if it helps with tooling (or disambiguating with other potential formats) could keep it 🤷
- Could also remove the
- Use some other format like XID or perhaps did:peer to effectively represent
hash(public key) - Make up a new format to use here
There was a problem hiding this comment.
Both points are fair. b1952a0 changes docs/key-types.md in two ways:
- The Peer ID now has a byte recipe and a test vector. An HTTP-only implementer builds it from the 32 raw Ed25519 bytes, with no libp2p spec to read and no libp2p library to link.
- Any new key type drops the protobuf wrapper and uses base36 over the key's own multicodec, which is your first bullet. Large keys get hashed, the way libp2p already does for RSA, so the label fits in 63 octets.
Ed25519 stays as it is so the label matches /v1, and matches the peer ID that libp2p tooling and browsers already derive from that key. Dropping the wrapper saves 5 characters (62 down to 57) but gives one key two names.
Question for you: is that compatibility (*.<Ed25519-cidv1-libp2p-key-base26>.libp2p.direct looking the same in /v1 and /v2) worth keeping? If /v2 should stand on its own, I am happy to break it for Ed25519 too and have one rule for every key type. It only breaks /v2, which has no users yet.
On XID and did:peer, I would rather not take on a third-party spec from vague entity. did:key sits under W3C, and its Ed25519 encoding is pinned by Controlled Identifiers 1.0, a Recommendation, so it is the safer long-term bet. Multicodec plus multibase is already in the stack and self-describes the same way. So if we want to break interop for ED25519 for /v2, I'd go with did:key encoding, but do base36 to be case-insensitive and fit under DNS label length limits.
| ([RFC 8615](https://www.rfc-editor.org/rfc/rfc8615)) in the future, if this | ||
| API sees enough adoption. | ||
|
|
||
| The response body is the JWT (`Content-Type: application/jwt`), signed with the |
There was a problem hiding this comment.
How are you thinking the /v2 endpoint is most likely to be used?
Given that the client already needs to use RFC 9421 signatures as a client would it make sense to do it as a server as well?
I see that the JWT is cacheable and can be signed offline but:
- When is it going to be requested even a second time such that caching makes sense?
- Given there's an expiration time on the token anyway (here listed as 14 days), the private key seems like it will in practice be kept pretty accessible for automated use
If we were going to let it be cached forever and wanted to keep the private key cold/offline I'd get the idea, but I'm not sure I'm understanding the value here
There was a problem hiding this comment.
Agreed, the caching argument does not hold much value in grand scheme of things here. The proof is fetched once per registration, and the key has to be online to sign the request anyway, so a cacheable token bought nothing and added a second signature format.
Dropped JWT in 5b98154. The forge now POSTs to /.well-known/autotls/<did:key> and the node signs its response with RFC 9421 over @status and content-digest, using the same key as the request. POST keeps it out of caches (which will save us headache with stale cached values atuck at CDN breaking the flow), and the signature is bound to that specific fetch rather than valid for 14 days.
On usage: in my mind its a node already serving HTTP on a public address, no libp2p anywhere. It mounts HTTP handler client.OwnershipProofHandler (thanks to libp2p/go-libp2p#3509) and either calls SendChallengeV2 or plugs NewHTTPBrokeredDNS01Solver into certmagic.
The registration signature tag, the well-known proof path, and the proof JWT typ were branded p2p-forge, this repo's name. The feature is known as AutoTLS and another forge could implement the same wire protocol, so the names now use autotls: tag autotls-reg, path /.well-known/autotls/, typ autotls-ownership+jwt. No behavior change.
The proof was a cacheable EdDSA JWT the node served at a well-known
path. Initial review flagged that caching buys little when the key
is hot anyway, and it meant carrying a second signature scheme
(JWT) next to the RFC 9421 request signature.
Replace it with an RFC 9421-signed response: the forge POSTs to
/.well-known/autotls/<did:key>, the node answers 200 whose body is
the origin it controls, signed over ("@status" "content-digest")
with tag autotls-ownership. The forge verifies under the
registration key, confirms the digest matches the body, and checks
the body equals the origin it reached. POST keeps the response
uncacheable, so one scheme (RFC 9421 plus RFC 9530 Content-Digest,
as on the request) covers the whole protocol and golang-jwt is gone.
The proven server is an RFC 6454 origin (default port omitted), so
the spec cites the standard instead of a bespoke canonicalization.
Add a SigningKey container (NewEd25519SigningKey) so the public
client API stays stable when more key types arrive.
The certmagic solver drives a libp2p host: it advertises the peer's multiaddrs and the forge verifies them by dialback. That is a /v1 flow. /v2 is HTTP-only (an origin plus a served proof) and never fit here, so the WithRegistrationAPIVersion option, the auto fallback, and the register() dispatch only added a path that could not work through this solver. The solver now calls /v1 directly. /v2 stays reachable through SendChallengeV2 with an origin, and a dedicated /v2 solver comes next. Nothing released depended on the removed option (it was added earlier on this branch).
/v2 accepted both http origins (ownership proof) and libp2p multiaddrs (dialback), which pulled the libp2p stack into what is meant to be a plain HTTP API and duplicated the reachability check. Drop the dialback from /v2. The body now carries origins (http(s) only, at most 4 considered), the server verifies them with the ownership proof and nothing else, and SendChallengeV2 takes a SigningKey plus a list of origins instead of a libp2p private key and multiaddrs, so the client leg imports no go-libp2p. Request signing moves onto SigningKey. A libp2p-only node registers through /v1, whose dialback is unchanged. Name the check HTTP-BROKERED-DNS-01: a key-bound artifact fetched over HTTP like ACME's HTTP-01, brokered because the forge runs it and translates the result into the DNS-01 it publishes. The success body reports it in a "challenge" field. The certmagic solver stays /v1 (it has a libp2p host); the shared challenge-body marshaling splits so /v1 keeps its multiaddr addresses field while /v2 uses origins.
The v2 verifier required exactly one signature, so a generic RFC 9421 tool that adds its own signature alongside sig1 was rejected, even though the server only ever reads sig1. Review flagged this as needless strictness. Require a sig1 signature and verify it against the full profile (exact components, order, no per-component params, alg); allow and ignore any other signatures. The grammar on sig1 itself is unchanged.
Give /v2 the same certmagic integration /v1 has, at two levels. NewHTTPBrokeredDNS01Solver is the libp2p-free primitive: an acmez.Solver that registers via SendChallengeV2 with a SigningKey and origins, for a caller driving certmagic itself. The DNS-01 TXT poll is extracted into a shared waitForTXT used by both solvers. WithHTTPBrokeredDNS01(key, origins) is the high-level path: it swaps P2PForgeCertMgr's internal solver to the v2 one and leaves the rest of the manager (storage, renewal, TLS config, the libp2p address factory) untouched, so a libp2p node moves from v1 to v2 by adding one option and mounting OwnershipProofHandler. The option builds the primitive underneath. Named after the HTTP-BROKERED-DNS-01 challenge rather than "HTTP registration", since /v1 also sends HTTP to the forge.
The DNS and cert label was called the "libp2p peer ID", derived "with peer.IDFromPublicKey", a Go symbol into the libp2p stack that a pure-HTTP /v2 client should not need. Keep the name Peer ID (p2p-forge is peer-to-peer) but define it generically: a peer named by a public key it proves it controls (Ed25519 on /v2, other types via /v1), computed by a self-contained byte recipe (constant 0x08011220 protobuf prefix, identity multihash, CIDv1 libp2p-key, base36) with a test vector matching the spec example did:key. The recipe matches libp2p's, so the Peer ID stays byte-identical to a libp2p peer ID for migration, stated once as compatibility rather than a dependency. The keyid subsection also moves below the signature it belongs to. Record the encoding decision: Ed25519 keeps the libp2p-compatible label for backward compatibility, new key types should use a did:key-style base36 multicodec label without the protobuf wrapper.
|
Thanks for the review @aschmahmann.
|
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
Start here
Read the docs, not the code. The code is a prototype and will change.
docs/registration-v2.mdis the/v2wire spec: request signing, the ownership challenge, errors.docs/key-types.mdcovers the DNS and cert identifier and what it takes to add a key type, post-quantum included.Problem
Getting a
*.libp2p.directcert through/v1means running a full libp2p client: a PeerID-auth handshake to authenticate, and a libp2p dialback to prove reachability. An HTTP server that only wants a cert for its own key is shut out.Fix
/v2runs next to/v1, which stays the default and is unchanged. It is pure HTTP: no multiaddrs, no dialback, and no go-libp2p on the client side. A client signs the request with an HTTP Message Signature1 over an Ed25519did:key2. To prove it controls a real endpoint, the forge POSTs to/.well-known/autotls/<did:key>and the node signs its response with the same key, so one signature scheme covers the whole flow. The forge vets and pins the resolved IP against SSRF3 and DNS rebinding4, and follows no redirects.Existing users are unaffected. A
/v2client opts in withWithHTTPBrokeredDNS01, or usesNewHTTPBrokeredDNS01Solverdirectly with certmagic (kubo side prototyped in ipfs/kubo#11333).Constraints
/v2is Ed25519-only for now; other key types stay on/v1.Open questions
did:key-style encoding. Is the compatibility worth keeping, or should/v2break away for Ed25519 too?did:keybinding and IANA algorithm registration to settle (Post-quantum key support in Kubo ipfs/kubo#11281).Footnotes
RFC 9421 (HTTP Message Signatures): a signature over a fixed set of message parts, producible by any RFC 9421 tool. ↩
W3C
did:key: a self-describing public-key identifier, so the wire format stays libp2p-agnostic. ↩SSRF: the forge fetches an address the caller picks, so an unguarded fetch could hit a cloud metadata service or internal host. ↩
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. ↩