Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9a3b2f5
feat: add RFC 9421 /v2 registration API
lidel Jul 15, 2026
fd8b6d6
feat(acme): harden the reachability dialback against SSRF
lidel Jul 15, 2026
3390cc9
feat(acme): replay guard and rate limiting for v2
lidel Jul 15, 2026
7bcc483
feat: add http-ownership proof for v2
lidel Jul 15, 2026
0d2a922
docs: document the /v2 registration API
lidel Jul 15, 2026
4eea510
refactor(acme): drop identify-event agent-version lookup
lidel Jul 15, 2026
32bc2f4
refactor: prove endpoint ownership with a JWT
lidel Jul 15, 2026
b3bba33
refactor: sign v2 requests with yaronf/httpsign
lidel Jul 15, 2026
01dd4d3
refactor: remove the per-IP rate limiter
lidel Jul 15, 2026
fb2c327
fix: enforce the full v2 signature profile
lidel Jul 17, 2026
05e268b
fix: canonicalize IP-literal proof origins
lidel Jul 17, 2026
41fab00
fix: constant-time forge auth comparison
lidel Jul 17, 2026
1c0bcd6
chore: changelog heading and comment fixes
lidel Jul 17, 2026
3566ede
fix(acme): drop webpki attempt from ownership fetch
lidel Jul 24, 2026
a7d3685
refactor: remove dead clock-skew allowance from v2
lidel Jul 24, 2026
7d48e40
refactor: drop the GET /v2 descriptor
lidel Jul 24, 2026
c3f4d58
refactor(acme): drop the v2 single-use nonce store
lidel Jul 24, 2026
3c1ca55
docs: keep the forge auth token out of the v2 spec
lidel Jul 24, 2026
0bc9f27
fix: enforce the closed v2 signature grammar
lidel Jul 24, 2026
77a3c8f
fix: harden the http-ownership proof
lidel Jul 24, 2026
86a62bd
fix: tighten the reachability probe guards
lidel Jul 24, 2026
4732bda
fix: check the client-IP denylist before signing work
lidel Jul 24, 2026
d2b1308
refactor(client): dedup the challenge send paths
lidel Jul 24, 2026
fe4253b
refactor(client): extract and test the v2/v1 dispatch
lidel Jul 24, 2026
977df63
fix: make the v2 success body and error links honest
lidel Jul 24, 2026
90b94f4
docs: fix and clarify the key-types support matrix
lidel Jul 24, 2026
6bd03f9
docs: align changelog with the final v2 config
lidel Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- ✨ New `/v2` registration API that authenticates with [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421) plus [Digest Fields (RFC 9530)](https://www.rfc-editor.org/rfc/rfc9530) over an Ed25519 `did:key`, so a client can register without a libp2p HTTP stack. A node proves it controls a real endpoint either with a signed proof served at `/.well-known/p2p-forge/<did:key>` (no libp2p) or with the libp2p dialback. The `/v1` PeerID-auth API is unchanged and runs alongside it. Clients opt in with `client.WithRegistrationAPIVersion`. See [docs/registration-v2.md](docs/registration-v2.md).
- New `acme` config: `allow-private-addresses=true` (an argument on the `registration-domain` line that turns off every reachability safeguard for local testing or trusted private deployments, default false) and the `client-ip-header` directive (names the trusted proxy header for the real client IP, e.g. `CF-Connecting-IP`; `X-Forwarded-For` is refused).

### Changed
- The reachability dialback now refuses to dial non-public destinations (loopback, RFC1918, CGNAT, link-local, cloud-metadata, and IPv4-embedding IPv6 ranges), pins resolved IPs against DNS rebinding, caps the address count, and bounds the dial with a timeout. Set `allow-private-addresses true` to restore the previous behavior for local testing.

### Fixed
- `X-Forwarded-For` is no longer trusted for the client IP used by the denylist. Only the direct connection address, plus a header named via `client-ip-header`, is trusted, so a client can no longer forge its way around an IP denylist entry.

## [v0.9.1] - 2026-06-22

Expand Down
40 changes: 17 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ ipparser FORGE_DOMAIN

~~~
acme FORGE_DOMAIN {
[registration-domain REGISTRATION_DOMAIN [listen-address=ADDRESS] [external-tls=true|false]
[registration-domain REGISTRATION_DOMAIN [listen-address=ADDRESS] [external-tls=true|false] [allow-private-addresses=true|false]
[client-ip-header HEADER_NAME]
[database-type DB_TYPE [...DB_ARGS]]
}
~~~
Expand All @@ -167,6 +168,8 @@ acme FORGE_DOMAIN {
- **REGISTRATION_DOMAIN** the HTTP API domain used by clients to send requests for setting ACME challenges (e.g. `registration.libp2p.direct`)
- **ADDRESS** is the address and port for the internal HTTP server to listen on (e.g. :1234), defaults to `:443`.
- `external-tls` should be set to `true` if the TLS termination (and validation of the registration domain name) will happen externally or should be handled locally, defaults to false
- `allow-private-addresses` turns off every reachability safeguard: destination-IP vetting, the address caps, the dialback IP pinning, and the verification timeouts. Defaults to false. Use it only for local testing or a private deployment that trusts submitted addresses; never on a public instance.
- **HEADER_NAME** (`client-ip-header`) names the header the fronting proxy sets with the real client IP (e.g. `CF-Connecting-IP` behind Cloudflare), used for the denylist. The value may be a bare IP or `ip:port`. It must be a single-value header your proxy controls; `X-Forwarded-For` is refused at startup because a client can prepend to it. Without it, only the direct connection address is trusted. Request rate limiting is not done by the forge; configure it on your reverse proxy, CDN, or load balancer.
- **DB_TYPE** is the type of the backing database used for storing the ACME challenges. Options include:
- `dynamo TABLE_NAME` for production-grade key-value store shared across multiple instances (where all credentials are set via AWS' standard environment variables: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
- `badger DB_PATH` for local key-value store (good for local development and testing)
Expand Down Expand Up @@ -225,30 +228,21 @@ Other address formats (e.g. the dual IPv6/IPv4 format) are not supported

### Submitting Challenge Records

To claim a domain name like `<peerID>.libp2p.direct` requires:
1. The private key corresponding to the given peerID
2. A publicly reachable libp2p endpoint with
- one of the following libp2p transport configurations:
- QUIC-v1
- TCP or WS or WSS, Yamux, TLS or Noise
- WebTransport
- Other transports are under consideration (e.g. HTTP), if they are of interest please file an issue
- the [Identify protocol](https://github.com/libp2p/specs/tree/master/identify) (`/ipfs/id/1.0.0`)

To set an ACME challenge send an HTTP request to the server (for libp2p.direct this is registration.libp2p.direct)
```shell
curl -X POST "https://registration.libp2p.direct/v1/_acme-challenge" \
-H "Authorization: libp2p-PeerID bearer=\"<base64-encoded-opaque-blob>\""
-H "Content-Type: application/json" \
-d '{
"Value": "your_acme_challenge_token",
"Addresses": ["your", "multiaddrs"]
}'
```
To claim a name like `<peerID>.libp2p.direct` a node proves two things to the
forge: it holds the private key for `<peerID>`, and it controls a real, publicly
reachable endpoint. There are two registration APIs:

- **`/v1`** authenticates with the libp2p HTTP PeerID-auth handshake and proves
reachability with a libp2p dialback. It requires a libp2p client. The
client-facing flow is specified in the libp2p [AutoTLS client spec](https://github.com/libp2p/specs/blob/master/tls/autotls-client.md).
- **`/v2`** authenticates with HTTP Message Signatures (RFC 9421) over an
Ed25519 `did:key`, so any HTTP client can register without libp2p. Reachability
is proven either by a signed proof served over HTTP or by a libp2p dialback.
See [docs/registration-v2.md](docs/registration-v2.md).

Where the bearer token is derived via the [libp2p HTTP PeerID Auth Specification](https://github.com/libp2p/specs/blob/master/http/peer-id-auth.md).
Both APIs write the same DNS-01 record and run side by side.

### Health Check

`/v1/health` will always respond with HTTP 204
`/v1/health` and `/v2/health` always respond with HTTP 204.

49 changes: 31 additions & 18 deletions acme/clientip.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,31 @@ import (
"github.com/multiformats/go-multiaddr"
)

// clientIPs extracts client IPs from request: both X-Forwarded-For and RemoteAddr.
// Returns all valid IPs found (may be 0, 1, or 2 IPs).
// clientIPs returns the client IPs to check against the denylist: the direct
// connection address, plus the address from trustedHeader when the operator has
// configured one (e.g. CF-Connecting-IP behind Cloudflare).
//
// X-Forwarded-For spoofing is not a security concern here because:
// 1. We also check all IPs from the multiaddrs in the request body
// 2. The actual A/AAAA record being requested must match a multiaddr IP
// 3. An attacker cannot spoof the multiaddr IPs they're connecting from
//
// The client IP check is defense-in-depth; the multiaddr check is authoritative.
func clientIPs(r *http.Request) []netip.Addr {
// A leftmost X-Forwarded-For is never trusted: any client can forge it, which
// would let an attacker dodge an IP denylist entry or a per-IP rate limit. Only
// a header the deployment's proxy is known to set, named via the
// client-ip-header option, is honored.
func clientIPs(r *http.Request, trustedHeader string) []netip.Addr {
var ips []netip.Addr

// Check X-Forwarded-For (leftmost = original client)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.Index(xff, ","); comma != -1 {
xff = xff[:comma]
}
xff = strings.TrimSpace(xff)
if ip, err := netip.ParseAddr(xff); err == nil {
ips = append(ips, ip)
if trustedHeader != "" {
if v := strings.TrimSpace(r.Header.Get(trustedHeader)); v != "" {
if ip, ok := parseHeaderIP(v); ok {
ips = append(ips, ip)
} else {
// The proxy is expected to set a single IP here. A value we
// cannot parse means the denylist would silently fall back to
// the proxy's own address, so make the misconfiguration
// visible instead.
log.Warningf("%s header value %q is not an IP; ignoring it for the denylist", trustedHeader, v)
}
}
}

// Also check RemoteAddr (direct connection IP)
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
Expand All @@ -44,6 +45,18 @@ func clientIPs(r *http.Request) []netip.Addr {
return ips
}

// parseHeaderIP parses a proxy-set client-IP header value, accepting either a
// bare IP or an "ip:port" pair (some proxies append the source port).
func parseHeaderIP(v string) (netip.Addr, bool) {
if ip, err := netip.ParseAddr(v); err == nil {
return ip, true
}
if ap, err := netip.ParseAddrPort(v); err == nil {
return ap.Addr(), true
}
return netip.Addr{}, false
}

// multiaddrsToIPs extracts IP addresses from multiaddr strings.
func multiaddrsToIPs(addrs []string) []netip.Addr {
ips := make([]netip.Addr, 0, len(addrs))
Expand Down
84 changes: 44 additions & 40 deletions acme/clientip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,65 +9,69 @@ import (
)

func TestClientIPs(t *testing.T) {
const trustedHeader = "CF-Connecting-IP"
tests := []struct {
name string
xff string
remoteAddr string
expected []netip.Addr
name string
trustedHeader string // the header name to trust, "" = trust none
headers map[string]string
remoteAddr string
expected []netip.Addr
}{
{
name: "XFF single IP",
xff: "1.2.3.4",
remoteAddr: "",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
name: "leftmost XFF is never trusted",
headers: map[string]string{"X-Forwarded-For": "1.2.3.4, 5.6.7.8"},
remoteAddr: "9.9.9.9:80",
expected: []netip.Addr{netip.MustParseAddr("9.9.9.9")},
},
{
name: "XFF multiple IPs uses leftmost",
xff: "1.2.3.4, 5.6.7.8, 9.10.11.12",
remoteAddr: "",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
name: "trusted header is honored",
trustedHeader: trustedHeader,
headers: map[string]string{trustedHeader: "1.2.3.4"},
remoteAddr: "9.9.9.9:80",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("9.9.9.9")},
},
{
name: "RemoteAddr IPv4 with port",
xff: "",
remoteAddr: "1.2.3.4:8080",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
name: "spoofed XFF ignored even with trusted header configured",
trustedHeader: trustedHeader,
headers: map[string]string{"X-Forwarded-For": "1.2.3.4"},
remoteAddr: "9.9.9.9:80",
expected: []netip.Addr{netip.MustParseAddr("9.9.9.9")},
},
{
name: "RemoteAddr IPv6 with port",
xff: "",
remoteAddr: "[::1]:8080",
expected: []netip.Addr{netip.MustParseAddr("::1")},
},
{
name: "both XFF and RemoteAddr",
xff: "1.2.3.4",
remoteAddr: "5.6.7.8:8080",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")},
name: "RemoteAddr without port",
remoteAddr: "1.2.3.4",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "empty headers",
xff: "",
remoteAddr: "",
expected: nil,
name: "invalid trusted header value skipped",
trustedHeader: trustedHeader,
headers: map[string]string{trustedHeader: "not-an-ip"},
remoteAddr: "1.2.3.4:80",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "XFF with spaces",
xff: " 1.2.3.4 ",
remoteAddr: "",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
name: "trusted header with ip:port is accepted",
trustedHeader: trustedHeader,
headers: map[string]string{trustedHeader: "1.2.3.4:5678"},
remoteAddr: "9.9.9.9:80",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("9.9.9.9")},
},
{
name: "invalid XFF skipped",
xff: "not-an-ip",
remoteAddr: "1.2.3.4:80",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
name: "trusted header with bracketed ipv6 and port is accepted",
trustedHeader: trustedHeader,
headers: map[string]string{trustedHeader: "[2001:db8::1]:443"},
remoteAddr: "9.9.9.9:80",
expected: []netip.Addr{netip.MustParseAddr("2001:db8::1"), netip.MustParseAddr("9.9.9.9")},
},
{
name: "RemoteAddr without port",
xff: "",
remoteAddr: "1.2.3.4",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
name: "empty",
remoteAddr: "",
expected: nil,
},
}

Expand All @@ -77,11 +81,11 @@ func TestClientIPs(t *testing.T) {
Header: make(http.Header),
RemoteAddr: tt.remoteAddr,
}
if tt.xff != "" {
r.Header.Set("X-Forwarded-For", tt.xff)
for k, v := range tt.headers {
r.Header.Set(k, v)
}

got := clientIPs(r)
got := clientIPs(r, tt.trustedHeader)
assert.Equal(t, tt.expected, got)
})
}
Expand Down
Loading
Loading