Skip to content

feat: libp2p-agnostic /v2 registration api - #92

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

feat: libp2p-agnostic /v2 registration api#92
lidel wants to merge 34 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

Start here

Read the docs, not the code. The code is a prototype and will change.

  1. docs/registration-v2.md is the /v2 wire spec: request signing, the ownership challenge, errors.
  2. docs/key-types.md covers the DNS and cert identifier and what it takes to add a key type, post-quantum included.

Problem

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

/v2 runs 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 Ed25519 did: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 /v2 client opts in with WithHTTPBrokeredDNS01, or uses NewHTTPBrokeredDNS01Solver directly with certmagic (kubo side prototyped in ipfs/kubo#11333).

Constraints

  • /v2 is Ed25519-only for now; other key types stay on /v1.
  • Build on published RFCs where possible; invent protocol only to close a real gap.
  • The forge does not rate-limit. That belongs on the fronting proxy, CDN, or load balancer.

Open questions

  • The Ed25519 label still matches the libp2p peer ID for that key. New key types will not, they use a did:key-style encoding. Is the compatibility worth keeping, or should /v2 break away for Ed25519 too?
  • Post-quantum is designed for but not enabled: adding ML-DSA needs no breaking change, but it needs its did:key binding and IANA algorithm registration to settle (Post-quantum key support in Kubo ipfs/kubo#11281).

Footnotes

  1. RFC 9421 (HTTP Message Signatures): a signature over a fixed set of message 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. SSRF: the forge fetches an address the caller picks, so an unguarded fetch could hit a cloud metadata service or internal host.

  4. 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 69.47368% with 232 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.90%. Comparing base (d629320) to head (b1952a0).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
acme/dialguard.go 59.50% 34 Missing and 15 partials ⚠️
acme/ownership.go 57.40% 34 Missing and 12 partials ⚠️
acme/verify_v2.go 67.50% 13 Missing and 13 partials ⚠️
acme/writer_v2.go 74.44% 16 Missing and 7 partials ⚠️
client/signingkey.go 72.72% 9 Missing and 9 partials ⚠️
client/challenge_v2.go 65.00% 7 Missing and 7 partials ⚠️
client/ownership.go 71.73% 8 Missing and 5 partials ⚠️
client/solver_v2.go 77.77% 10 Missing and 2 partials ⚠️
internal/httpsig/didkey.go 74.19% 4 Missing and 4 partials ⚠️
acme/writer.go 65.00% 3 Missing and 4 partials ⚠️
... and 4 more
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.
📢 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 14 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.
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 aschmahmann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @lidel for taking a stab here. Left some comments (hopefully that make things easier), lmk what you think

Comment thread docs/registration-v2.md Outdated
Comment thread docs/registration-v2.md Outdated
Comment thread docs/registration-v2.md Outdated
| `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`. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 😅)

@lidel lidel Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/registration-v2.md
Comment on lines +96 to +99
`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessarily the case? For example:

  1. 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?
  2. 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/registration-v2.md Outdated

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it strictly matter if other signatures are appended as long as everyone knows the server is only going to look at sig1?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/registration-v2.md Outdated
Comment on lines +167 to +171
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@lidel lidel Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/registration-v2.md Outdated
Comment on lines +183 to +186
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/registration-v2.md Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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 0x01 as unnecessary, but if it helps with tooling (or disambiguating with other potential formats) could keep it 🤷
  • Use some other format like XID or perhaps did:peer to effectively represent hash(public key)
  • Make up a new format to use here

@lidel lidel Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/registration-v2.md Outdated
([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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. When is it going to be requested even a second time such that caching makes sense?
  2. 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

@lidel lidel Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

lidel added 7 commits July 24, 2026 23:41
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.
@lidel

lidel commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @aschmahmann.
Pushed seven commits on top of what you read; the ones that matter:

  • /v2 is pure HTTP now: no multiaddrs, no libp2p dialback, and the client side imports no go-libp2p (1abfa56). The reachability check is now framed as an ACME-style challenge, HTTP-BROKERED-DNS-01.
  • The ownership proof is no longer a JWT. The forge POSTs to the node's well-known path and the node signs its response with RFC 9421, so one signature scheme covers the whole flow (5b98154), less magic to implement and spec
  • The DNS and cert label is defined by its bytes with a test vector, instead of by a libp2p Go symbol (b1952a0).
  • /v2 gets a certmagic solver and a cert-manager option mirroring /v1, so kubo can wire it up without a refactor (a6a45d2).

docs/registration-v2.md and docs/key-types.md are the best re-read. I also answered each thread above with what changed, and left one question open for you in #92 (comment): whether the Ed25519 label should keep matching the libp2p peer ID, or break away like new key types will.

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