diff --git a/CHANGELOG.md b/CHANGELOG.md index 540ca90..e337a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` (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 diff --git a/README.md b/README.md index 6edc8de..85ad52c 100644 --- a/README.md +++ b/README.md @@ -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]] } ~~~ @@ -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) @@ -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 `.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=\"\"" --H "Content-Type: application/json" \ --d '{ - "Value": "your_acme_challenge_token", - "Addresses": ["your", "multiaddrs"] -}' -``` +To claim a name like `.libp2p.direct` a node proves two things to the +forge: it holds the private key for ``, 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. diff --git a/acme/clientip.go b/acme/clientip.go index 16729cc..dfee0fa 100644 --- a/acme/clientip.go +++ b/acme/clientip.go @@ -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 @@ -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)) diff --git a/acme/clientip_test.go b/acme/clientip_test.go index 2df8e8b..1547788 100644 --- a/acme/clientip_test.go +++ b/acme/clientip_test.go @@ -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, }, } @@ -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) }) } diff --git a/acme/dialguard.go b/acme/dialguard.go new file mode 100644 index 0000000..46cefd6 --- /dev/null +++ b/acme/dialguard.go @@ -0,0 +1,266 @@ +package acme + +import ( + "context" + "fmt" + "net/netip" + "time" + + "github.com/ipshipyard/p2p-forge/denylist" + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/control" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/multiformats/go-multiaddr" + madns "github.com/multiformats/go-multiaddr-dns" + manet "github.com/multiformats/go-multiaddr/net" +) + +// probeTimeout bounds a whole reachability probe, DNS resolution included, so +// a slow or blackholed target cannot pin a request goroutine and its libp2p +// host. +const probeTimeout = 15 * time.Second + +// maxProbeAddresses bounds how many submitted addresses one registration may +// make the forge resolve and dial; addresses beyond the cap are ignored, not +// rejected. Enforced only when address vetting is on (see +// acmeWriter.AllowPrivateAddrs). +const maxProbeAddresses = 32 + +// blockedPrefixes are IP ranges the forge must never dial: they reach internal, +// link-local, IPv4-embedding, or otherwise non-globally-routable targets (cloud +// metadata, RFC1918, CGNAT, NAT64, 6to4, Teredo, IPv4-compatible IPv6, reserved +// space). netip's own predicates cover loopback, unspecified, link-local, +// multicast, RFC1918, and ULA; these are the gaps it does not. +var blockedPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), // "this host on this network" (RFC 6890) + netip.MustParsePrefix("100.64.0.0/10"), // CGNAT (RFC 6598) + netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments, incl. DS-Lite (RFC 6890, RFC 7335) + netip.MustParsePrefix("192.88.99.0/24"), // deprecated 6to4 relay anycast (RFC 7526) + netip.MustParsePrefix("198.18.0.0/15"), // benchmarking (RFC 2544) + netip.MustParsePrefix("240.0.0.0/4"), // reserved / Class E + netip.MustParsePrefix("::/96"), // IPv4-compatible IPv6 (deprecated, embeds v4) + netip.MustParsePrefix("100::/64"), // discard-only (RFC 6666) + netip.MustParsePrefix("64:ff9b::/96"), // NAT64 well-known (RFC 6052) + netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 local-use (RFC 8215) + netip.MustParsePrefix("2002::/16"), // 6to4 (RFC 3056) + netip.MustParsePrefix("2001::/32"), // Teredo (RFC 4380) +} + +// vetDestIP reports whether ip is a public, globally routable unicast address +// the forge may dial. It unmaps IPv4-in-IPv6 first so an embedded private v4 +// cannot slip through as a v6 literal. +func vetDestIP(ip netip.Addr) error { + ip = ip.Unmap() + if !ip.IsValid() { + return fmt.Errorf("invalid IP") + } + switch { + case ip.IsLoopback(): + return fmt.Errorf("loopback IP %s", ip) + case ip.IsUnspecified(): + return fmt.Errorf("unspecified IP %s", ip) + case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(): + return fmt.Errorf("link-local IP %s", ip) + case ip.IsMulticast(), ip.IsInterfaceLocalMulticast(): + return fmt.Errorf("multicast IP %s", ip) + case ip.IsPrivate(): // RFC1918 and ULA fc00::/7 + return fmt.Errorf("private IP %s", ip) + case !ip.IsGlobalUnicast(): + return fmt.Errorf("non-global IP %s", ip) + } + for _, p := range blockedPrefixes { + if p.Contains(ip) { + return fmt.Errorf("reserved IP %s (%s)", ip, p) + } + } + return nil +} + +// multiaddrIP extracts the IP literal from a multiaddr, or false if it carries +// none (e.g. a /dns or relay address). +func multiaddrIP(m multiaddr.Multiaddr) (netip.Addr, bool) { + ip, err := manet.ToIP(m) + if err != nil { + return netip.Addr{}, false + } + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return netip.Addr{}, false + } + return addr.Unmap(), true +} + +// ipGater is a libp2p ConnectionGater that permits dials only to a fixed set of +// pre-vetted IPs. It closes any DNS-rebinding gap left after resolution by +// pinning the exact IPs the forge decided to dial, for TCP and QUIC alike. +type ipGater struct { + allowed map[netip.Addr]struct{} +} + +func newIPGater(ips []netip.Addr) *ipGater { + allowed := make(map[netip.Addr]struct{}, len(ips)) + for _, ip := range ips { + allowed[ip] = struct{}{} + } + return &ipGater{allowed: allowed} +} + +func (g *ipGater) permitted(m multiaddr.Multiaddr) bool { + ip, ok := multiaddrIP(m) + if !ok { + return false + } + _, ok = g.allowed[ip] + return ok +} + +func (g *ipGater) InterceptAddrDial(_ peer.ID, m multiaddr.Multiaddr) bool { + return g.permitted(m) +} + +func (g *ipGater) InterceptPeerDial(peer.ID) bool { return true } + +func (g *ipGater) InterceptAccept(network.ConnMultiaddrs) bool { return true } + +func (g *ipGater) InterceptSecured(network.Direction, peer.ID, network.ConnMultiaddrs) bool { + return true +} + +func (g *ipGater) InterceptUpgraded(network.Conn) (bool, control.DisconnectReason) { + return true, 0 +} + +// isCircuit reports whether m contains a /p2p-circuit relay hop. +func isCircuit(m multiaddr.Multiaddr) bool { + for _, p := range m.Protocols() { + if p.Code == multiaddr.P_CIRCUIT { + return true + } + } + return false +} + +// resolveAndVet parses the submitted addresses, resolves any /dns* component to +// concrete IPs, drops relay and non-public addresses (unless allowPrivate), and +// returns the concrete multiaddrs to dial plus the deduped IP set to pin. It +// errors if nothing dialable remains. +func resolveAndVet(ctx context.Context, resolver *madns.Resolver, addrStrs []string, allowPrivate bool) ([]multiaddr.Multiaddr, []netip.Addr, error) { + var ( + dialable []multiaddr.Multiaddr + ips []netip.Addr + seen = map[netip.Addr]struct{}{} + inputs int + ) +outer: + for _, s := range addrStrs { + m, err := multiaddr.NewMultiaddr(s) + if err != nil { + return nil, nil, fmt.Errorf("parsing address %q: %w", s, err) + } + if isCircuit(m) { + continue + } + // Bound the resolution work: addresses past the cap are ignored, so a + // long list cannot make the forge resolve dozens of names. + if !allowPrivate { + if inputs++; inputs > maxProbeAddresses { + break + } + } + + resolved := []multiaddr.Multiaddr{m} + if madns.Matches(m) { + resolved, err = resolver.Resolve(ctx, m) + if err != nil { + continue + } + } + for _, rm := range resolved { + ip, ok := multiaddrIP(rm) + if !ok { + continue // no IP literal to vet or pin + } + if !allowPrivate { + if err := vetDestIP(ip); err != nil { + continue // never dial a non-public target + } + } + dialable = append(dialable, rm) + if _, dup := seen[ip]; !dup { + seen[ip] = struct{}{} + ips = append(ips, ip) + } + // Bound the dial fan-out after resolution, since one /dns* input + // can expand to many IPs. + if !allowPrivate && len(dialable) >= maxProbeAddresses { + break outer + } + } + } + if len(dialable) == 0 { + return nil, nil, fmt.Errorf("no dialable public address") + } + return dialable, ips, nil +} + +// testAddresses verifies the peer is reachable and authenticates as p by +// dialing its addresses over libp2p. When address vetting is enabled (the +// default) it caps the address count, refuses non-public targets, pins the +// vetted IPs with a connection gater against DNS rebinding, and bounds the +// whole probe, resolution included, with a timeout. +func (c *acmeWriter) testAddresses(ctx context.Context, p peer.ID, addrStrs []string, httpUserAgent string) error { + agentVersion := agentType(httpUserAgent) + + // The timeout covers everything a probe does, resolution included: /dns* + // inputs resolved against a tarpit nameserver could otherwise hold this + // goroutine for minutes, and the v1 handler calls in with no timeout of + // its own. + if !c.AllowPrivateAddrs { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, probeTimeout) + defer cancel() + } + + dialable, ips, err := resolveAndVet(ctx, madns.DefaultResolver, addrStrs, c.AllowPrivateAddrs) + if err != nil { + recordPeerProbe("error", agentVersion) + return err + } + + // Close the /dns denylist gap: block on the resolved IPs, which + // checkDenylist's multiaddrsToIPs never sees behind a name. + if mgr := denylist.GetManager(); mgr != nil { + for _, ip := range ips { + if denied, res := mgr.Check(ip); denied { + recordPeerProbe("error", agentVersion) + return fmt.Errorf("address IP %s blocked by %s", ip, res.Name) + } + } + } + + opts := []libp2p.Option{libp2p.NoListenAddrs, libp2p.DisableRelay()} + if !c.AllowPrivateAddrs { + opts = append(opts, libp2p.ConnectionGater(newIPGater(ips))) + } + + h, err := libp2p.New(opts...) + if err != nil { + recordPeerProbe("error", agentVersion) + return err + } + defer h.Close() + + if err := h.Connect(ctx, peer.AddrInfo{ID: p, Addrs: dialable}); err != nil { + recordPeerProbe("error", agentVersion) + // Return a generic error: the underlying dial result (refused vs + // handshake-fail vs timeout) would let a caller port-scan public hosts + // through the forge. The detail is logged server-side only. + log.Debugf("probe dial to %s failed: %v", p, err) + return fmt.Errorf("peer is not reachable at any submitted address") + } + + log.Debugf("connected to peer %s (agent %q)", p, agentVersion) + recordPeerProbe("ok", agentVersion) + return nil +} diff --git a/acme/dialguard_test.go b/acme/dialguard_test.go new file mode 100644 index 0000000..75a0d8c --- /dev/null +++ b/acme/dialguard_test.go @@ -0,0 +1,118 @@ +package acme + +import ( + "bytes" + "encoding/base64" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/libp2p/go-libp2p/core/peer" + "github.com/multiformats/go-multiaddr" + madns "github.com/multiformats/go-multiaddr-dns" + "github.com/stretchr/testify/require" +) + +func TestVetDestIP(t *testing.T) { + blocked := []string{ + "127.0.0.1", // loopback + "::1", // loopback v6 + "0.0.0.0", // unspecified + "10.1.2.3", // RFC1918 + "192.168.0.1", // RFC1918 + "172.16.5.5", // RFC1918 + "169.254.169.254", // link-local (cloud metadata) + "fe80::1", // link-local v6 + "100.64.0.1", // CGNAT + "fc00::1", // ULA + "::ffff:10.0.0.1", // IPv4-mapped private + "::ffff:169.254.169.254", // IPv4-mapped metadata + "64:ff9b::a9fe:a9fe", // NAT64 of 169.254.169.254 + "64:ff9b:1::1", // NAT64 local-use + "2002:a9fe:a9fe::1", // 6to4 + "2001::1", // Teredo + "224.0.0.1", // multicast + "0.1.2.3", // 0.0.0.0/8 "this host" + "::a9fe:a9fe", // IPv4-compatible IPv6 of 169.254.169.254 + "::7f00:1", // IPv4-compatible IPv6 of 127.0.0.1 + "198.18.0.1", // benchmarking + "240.0.0.1", // reserved / Class E + "255.255.255.255", // broadcast + "192.0.0.170", // IETF protocol assignments (RFC 6890) + "192.0.0.2", // DS-Lite CPE (RFC 7335) + "192.88.99.1", // deprecated 6to4 relay anycast + "100::1", // discard-only (RFC 6666) + } + for _, s := range blocked { + t.Run("blocked/"+s, func(t *testing.T) { + require.Error(t, vetDestIP(netip.MustParseAddr(s)), "%s must be rejected", s) + }) + } + + allowed := []string{ + "1.1.1.1", + "8.8.8.8", + "203.0.113.7", + "2606:4700:4700::1111", + } + for _, s := range allowed { + t.Run("allowed/"+s, func(t *testing.T) { + require.NoError(t, vetDestIP(netip.MustParseAddr(s)), "%s must be accepted", s) + }) + } +} + +// TestVettingRejectsPrivate confirms the probe refuses a loopback/private +// target when vetting is on (AllowPrivateAddrs=false), rather than dialing it. +func TestVettingRejectsPrivate(t *testing.T) { + initMetrics() + _, priv := newRegistrantHost(t) + + c := newTestWriter() + c.AllowPrivateAddrs = false // enable vetting + + value := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x01}, 32)) + req := signedV2Request(t, priv, value, []string{"/ip4/127.0.0.1/tcp/4001", "/ip4/10.0.0.1/tcp/4001"}) + rec := httptest.NewRecorder() + c.handleV2Challenge(rec, req) + // No public address survives vetting, so the probe fails: 422. + require.Equal(t, 422, rec.Code, rec.Body.String()) +} + +func TestIPGaterPins(t *testing.T) { + gater := newIPGater([]netip.Addr{netip.MustParseAddr("203.0.113.7")}) + pid := peer.ID("") + + // Same IP over TCP and QUIC is permitted; a different IP is not. + tcp := multiaddr.StringCast("/ip4/203.0.113.7/tcp/4001") + quic := multiaddr.StringCast("/ip4/203.0.113.7/udp/4001/quic-v1") + other := multiaddr.StringCast("/ip4/198.51.100.9/tcp/4001") + require.True(t, gater.InterceptAddrDial(pid, tcp)) + require.True(t, gater.InterceptAddrDial(pid, quic)) + require.False(t, gater.InterceptAddrDial(pid, other)) + + // An address with no IP literal (e.g. a bare dns name) is not permitted. + dnsAddr := multiaddr.StringCast("/dns4/example.com/tcp/443") + require.False(t, gater.InterceptAddrDial(pid, dnsAddr)) +} + +func TestResolveAndVetFiltersPrivate(t *testing.T) { + addrs := []string{ + "/ip4/203.0.113.7/tcp/4001", // public: kept + "/ip4/10.0.0.5/tcp/4001", // RFC1918: dropped + "/ip4/127.0.0.1/tcp/4001", // loopback: dropped + } + dialable, ips, err := resolveAndVet(t.Context(), madns.DefaultResolver, addrs, false) + require.NoError(t, err) + require.Len(t, dialable, 1) + require.Equal(t, []netip.Addr{netip.MustParseAddr("203.0.113.7")}, ips) + + // With only private addresses and vetting on, nothing survives. + _, _, err = resolveAndVet(t.Context(), madns.DefaultResolver, []string{"/ip4/10.0.0.5/tcp/4001"}, false) + require.ErrorContains(t, err, "no dialable public address") + + // allowPrivate keeps them. + dialable, _, err = resolveAndVet(t.Context(), madns.DefaultResolver, []string{"/ip4/10.0.0.5/tcp/4001"}, true) + require.NoError(t, err) + require.Len(t, dialable, 1) +} diff --git a/acme/ownership.go b/acme/ownership.go new file mode 100644 index 0000000..5265b59 --- /dev/null +++ b/acme/ownership.go @@ -0,0 +1,218 @@ +package acme + +import ( + "context" + "crypto/ed25519" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/ipshipyard/p2p-forge/client" + "github.com/ipshipyard/p2p-forge/denylist" + "github.com/ipshipyard/p2p-forge/internal/ownership" +) + +const ( + // ownershipFetchTimeout bounds one proof fetch. + ownershipFetchTimeout = 15 * time.Second + // ownershipBodyLimit caps the proof response body (a compact JWT). + ownershipBodyLimit = 8 << 10 + // maxOwnershipURLs caps how many http endpoints one request may present. + maxOwnershipURLs = 8 + // overallVerifyTimeout bounds all reachability work for one registration, + // so a request cannot pin the forge on slow endpoints across many fetches. + overallVerifyTimeout = 45 * time.Second +) + +// verifyReachable proves the peer controls a submitted address, trying the +// no-libp2p http-ownership proof first and falling back to the libp2p dialback. +// It returns the verification mode that succeeded. +func (c *acmeWriter) verifyReachable(ctx context.Context, v *v2Verified, addrs []string, userAgent string) (string, error) { + if !c.AllowPrivateAddrs { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, overallVerifyTimeout) + defer cancel() + } + + httpURLs, libp2pAddrs := partitionAddrs(addrs) + + var lastErr error + if len(httpURLs) > 0 { + if err := c.verifyHTTPOwnership(ctx, v.pub, v.keyID, httpURLs); err == nil { + return "http-ownership", nil + } else { + lastErr = err + } + } + if len(libp2pAddrs) > 0 { + if err := c.testAddresses(ctx, v.peerID, libp2pAddrs, userAgent); err == nil { + return "libp2p-dialback", nil + } else { + lastErr = err + } + } + if lastErr == nil { + lastErr = errors.New("no verifiable address submitted") + } + return "", lastErr +} + +// partitionAddrs splits submitted addresses into http(s) origin URLs (verified +// by ownership proof) and everything else (verified by libp2p dialback). +// Schemes are case-insensitive (RFC 3986); CanonicalOrigin lowercases later. +func partitionAddrs(addrs []string) (httpURLs, libp2pAddrs []string) { + for _, a := range addrs { + lower := strings.ToLower(a) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + httpURLs = append(httpURLs, a) + } else { + libp2pAddrs = append(libp2pAddrs, a) + } + } + return httpURLs, libp2pAddrs +} + +// verifyHTTPOwnership tries each submitted http endpoint until one serves a +// valid ownership proof signed by the registration key pub. +func (c *acmeWriter) verifyHTTPOwnership(ctx context.Context, pub ed25519.PublicKey, keyID string, urls []string) error { + // URLs past the cap are ignored, not rejected, matching the multiaddr cap. + if !c.AllowPrivateAddrs && len(urls) > maxOwnershipURLs { + urls = urls[:maxOwnershipURLs] + } + var lastErr error + for _, raw := range urls { + o, err := client.CanonicalOrigin(raw) + if err != nil { + lastErr = err + continue + } + if err := c.fetchAndVerifyOwnership(ctx, pub, keyID, o); err != nil { + lastErr = err + continue + } + return nil + } + if lastErr == nil { + lastErr = errors.New("no http address proved ownership") + } + return lastErr +} + +// fetchAndVerifyOwnership fetches and checks the proof at o. Any port is +// allowed on purpose: a NATed node forwards an arbitrary port via UPnP or +// router config, and the libp2p dialback accepts arbitrary ports too. Abuse +// control rests on the public-IP vetting and the per-IP denylist, not the +// port. +func (c *acmeWriter) fetchAndVerifyOwnership(ctx context.Context, pub ed25519.PublicKey, keyID string, o client.HTTPOrigin) error { + ips, err := c.resolvePinnedIPs(ctx, o.Host) + if err != nil { + return err + } + var lastErr error + for _, ip := range ips { + if mgr := denylist.GetManager(); mgr != nil { + if denied, res := mgr.Check(ip); denied { + lastErr = fmt.Errorf("endpoint IP %s blocked by %s", ip, res.Name) + continue + } + } + proof, err := fetchOwnershipProof(ctx, o, ip, keyID) + if err != nil { + lastErr = err + continue + } + // A fetched proof is authoritative: verify it under the registration + // key (never a key the proof itself names) and stop either way. + return ownership.Verify(string(proof), pub, o.Origin, time.Now()) + } + return lastErr +} + +// resolvePinnedIPs resolves host to at most one vettable public IP per address +// family to pin fetches to, so one stale or unreachable record (say a dead +// AAAA) cannot sink the proof when the other family works. A literal IP is +// used directly. Vetting is skipped when AllowPrivateAddrs. +func (c *acmeWriter) resolvePinnedIPs(ctx context.Context, host string) ([]netip.Addr, error) { + if ip, err := netip.ParseAddr(host); err == nil { + if !c.AllowPrivateAddrs { + if err := vetDestIP(ip); err != nil { + return nil, err + } + } + return []netip.Addr{ip.Unmap()}, nil + } + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, fmt.Errorf("resolving %s: %w", host, err) + } + var picked []netip.Addr + var have4, have6 bool + for _, ip := range ips { + ip = ip.Unmap() + if !c.AllowPrivateAddrs && vetDestIP(ip) != nil { + continue + } + if ip.Is4() && !have4 { + picked = append(picked, ip) + have4 = true + } + if !ip.Is4() && !have6 { + picked = append(picked, ip) + have6 = true + } + } + if len(picked) == 0 { + return nil, fmt.Errorf("no public IP for %s", host) + } + return picked, nil +} + +// fetchOwnershipProof GETs the well-known proof, pinning the connection to ip so +// a DNS rebind cannot redirect it, following no redirects, and capping the body. +// The fetch never requires a CA-verified certificate: a node registers precisely +// because it has no publicly trusted cert yet, so authenticity rests on the +// proof's signature and host binding on the pinned, vetted IP, not on WebPKI. +func fetchOwnershipProof(ctx context.Context, o client.HTTPOrigin, ip netip.Addr, keyID string) ([]byte, error) { + proofURL := o.Origin + client.WellKnownProofPath + keyID + pinned := net.JoinHostPort(ip.String(), o.Port) + + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, pinned) // ignore the hostname; connect to the pinned IP + }, + DisableKeepAlives: true, + DisableCompression: true, + ResponseHeaderTimeout: ownershipFetchTimeout, + MaxResponseHeaderBytes: 16 << 10, + TLSClientConfig: &tls.Config{ServerName: o.Host, InsecureSkipVerify: true}, + } + httpClient := &http.Client{ + Transport: transport, + Timeout: ownershipFetchTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, proofURL, nil) + if err != nil { + return nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ownership endpoint returned %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, ownershipBodyLimit)) + if err != nil { + return nil, fmt.Errorf("reading ownership proof: %w", err) + } + return body, nil +} diff --git a/acme/ownership_test.go b/acme/ownership_test.go new file mode 100644 index 0000000..f0d9519 --- /dev/null +++ b/acme/ownership_test.go @@ -0,0 +1,178 @@ +package acme + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ipshipyard/p2p-forge/client" + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" +) + +// ed25519Pub returns priv's public key in the form verifyHTTPOwnership takes. +func ed25519Pub(t *testing.T, priv crypto.PrivKey) ed25519.PublicKey { + t.Helper() + raw, err := priv.GetPublic().Raw() + require.NoError(t, err) + return ed25519.PublicKey(raw) +} + +// newProofServer starts a loopback HTTP server that serves priv's ownership +// proof for its own origin, and returns the server plus the signer's did:key. +func newProofServer(t *testing.T, priv crypto.PrivKey) (*httptest.Server, string) { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + handler, err := client.OwnershipProofHandler(priv, srv.URL) + require.NoError(t, err) + mux.Handle("/", handler) + + did, err := httpsig.EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + return srv, did +} + +func TestHTTPOwnershipVerify(t *testing.T) { + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + srv, did := newProofServer(t, priv) + + c := newTestWriter() // AllowPrivateAddrs=true: loopback + non-standard port + + t.Run("valid proof verifies", func(t *testing.T) { + require.NoError(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{srv.URL})) + }) + + t.Run("wrong registration key fails", func(t *testing.T) { + other, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + otherDID, err := httpsig.EncodeDIDKey(other.GetPublic()) + require.NoError(t, err) + // The endpoint serves priv's proof; verifying it as other must fail + // (the served path is priv's did:key, so this 404s or key-mismatches). + require.Error(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, other), otherDID, []string{srv.URL})) + }) + + t.Run("no proof served fails", func(t *testing.T) { + bare := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(bare.Close) + require.Error(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{bare.URL})) + }) + + t.Run("redirect is not followed", func(t *testing.T) { + redirecting := httptest.NewServer(http.RedirectHandler(srv.URL, http.StatusMovedPermanently)) + t.Cleanup(redirecting.Close) + require.Error(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{redirecting.URL})) + }) +} + +func TestHTTPOwnershipSelfSignedTLS(t *testing.T) { + // A node registering because it has no CA-valid cert yet serves the proof + // over HTTPS with a self-signed cert; verification rests on the proof + // signature and the pinned IP, never on WebPKI. + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + mux := http.NewServeMux() + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + handler, err := client.OwnershipProofHandler(priv, srv.URL) + require.NoError(t, err) + mux.Handle("/", handler) + did, err := httpsig.EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + + c := newTestWriter() + require.NoError(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{srv.URL})) +} + +func TestV2HTTPOwnershipEndToEnd(t *testing.T) { + initMetrics() + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + srv, _ := newProofServer(t, priv) + + c := newTestWriter() + value := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x07}, 32)) + req := signedV2Request(t, priv, value, []string{srv.URL}) + rec := httptest.NewRecorder() + + c.handleV2Challenge(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp v2Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "http-ownership", resp.Verification) +} + +func TestCanonicalOriginServer(t *testing.T) { + // A path or userinfo in the address is rejected, so the signed origin + // cannot be widened. + for _, bad := range []string{ + "https://gw.example/some/path", + "https://user@gw.example", + "ftp://gw.example", + "https://gw.example?x=1", + "https://[fe80::1%25eth0]", // zoned IPv6: host-local, never verifiable + } { + _, err := client.CanonicalOrigin(bad) + require.Error(t, err, bad) + } + o, err := client.CanonicalOrigin("https://GW.Example") + require.NoError(t, err) + require.Equal(t, "https://gw.example:443", o.Origin) + + // IPv6 literals stay bracketed in the origin string (so it remains a + // valid URL) and IP literals collapse to one canonical textual form. + o, err = client.CanonicalOrigin("https://[2001:DB8:0::1]") + require.NoError(t, err) + require.Equal(t, "https://[2001:db8::1]:443", o.Origin) + require.Equal(t, "2001:db8::1", o.Host) + + // An IPv4-mapped IPv6 literal collapses to its IPv4 form. + o, err = client.CanonicalOrigin("http://[::ffff:1.2.3.4]") + require.NoError(t, err) + require.Equal(t, "http://1.2.3.4:80", o.Origin) +} + +func TestHTTPOwnershipIPv6Literal(t *testing.T) { + // An IPv6-literal endpoint must produce a fetchable proof URL and a + // matching origin claim on both sides. + ln, err := net.Listen("tcp", "[::1]:0") + require.NoError(t, err) + mux := http.NewServeMux() + srv := httptest.NewUnstartedServer(mux) + srv.Listener.Close() + srv.Listener = ln + srv.Start() + t.Cleanup(srv.Close) + + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + handler, err := client.OwnershipProofHandler(priv, srv.URL) + require.NoError(t, err) + mux.Handle("/", handler) + did, err := httpsig.EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + + // Pin the bracketed form itself: without this, both sides would agree on + // even a malformed origin string (they derive it through the same call) + // and the fetch, which dials the pinned IP rather than the URL host, + // would pass regardless. + o, err := client.CanonicalOrigin(srv.URL) + require.NoError(t, err) + require.Equal(t, "http://"+srv.Listener.Addr().String(), o.Origin) + require.Contains(t, o.Origin, "[::1]") + + c := newTestWriter() + require.NoError(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{srv.URL})) +} diff --git a/acme/setup.go b/acme/setup.go index 8781e93..8f67ebf 100644 --- a/acme/setup.go +++ b/acme/setup.go @@ -61,6 +61,8 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { var forgeDomain string var forgeRegistrationDomain string var externalTLS bool + var allowPrivateAddrs bool + var clientIPHeader string var httpListenAddr string var ds datastore.TTLDatastore @@ -80,7 +82,7 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { switch c.Val() { case "registration-domain": args := c.RemainingArgs() - if len(args) > 3 || len(args) == 0 { + if len(args) > 4 || len(args) == 0 { return nil, nil, c.ArgErr() } @@ -102,10 +104,29 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { if err != nil { return nil, nil, c.ArgErr() } + case "allow-private-addresses": + var err error + allowPrivateAddrs, err = strconv.ParseBool(v) + if err != nil { + return nil, nil, c.ArgErr() + } default: return nil, nil, c.ArgErr() } } + case "client-ip-header": + args := c.RemainingArgs() + if len(args) != 1 { + return nil, nil, c.ArgErr() + } + // X-Forwarded-For is a client-appendable list, so trusting it + // would hand the denylist a caller-forged leftmost value. Only + // a single-value header the proxy sets (e.g. CF-Connecting-IP) + // is safe here. + if strings.EqualFold(args[0], "X-Forwarded-For") { + return nil, nil, c.Errf("client-ip-header must not be X-Forwarded-For; use a single-value header your proxy sets, such as CF-Connecting-IP") + } + clientIPHeader = args[0] case "database-type": args := c.RemainingArgs() if len(args) == 0 { @@ -162,10 +183,13 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { initMetrics() writer := &acmeWriter{ - Addr: httpListenAddr, - Domain: forgeRegistrationDomain, - Datastore: ds, - ExternalTLS: externalTLS, + Addr: httpListenAddr, + Domain: forgeRegistrationDomain, + ForgeDomain: forgeDomain, + Datastore: ds, + ExternalTLS: externalTLS, + AllowPrivateAddrs: allowPrivateAddrs, + ClientIPHeader: clientIPHeader, } reader := &acmeReader{ ForgeDomain: forgeDomain, diff --git a/acme/setup_test.go b/acme/setup_test.go new file mode 100644 index 0000000..dd7af26 --- /dev/null +++ b/acme/setup_test.go @@ -0,0 +1,38 @@ +package acme + +import ( + "testing" + + "github.com/coredns/caddy" + "github.com/stretchr/testify/require" +) + +func TestParseClientIPHeader(t *testing.T) { + t.Run("a single-value header is accepted", func(t *testing.T) { + c := caddy.NewTestController("dns", `acme libp2p.direct { + registration-domain registration.libp2p.direct + client-ip-header CF-Connecting-IP + }`) + _, w, err := parse(c) + require.NoError(t, err) + require.Equal(t, "CF-Connecting-IP", w.ClientIPHeader) + }) + + t.Run("X-Forwarded-For is rejected", func(t *testing.T) { + c := caddy.NewTestController("dns", `acme libp2p.direct { + registration-domain registration.libp2p.direct + client-ip-header X-Forwarded-For + }`) + _, _, err := parse(c) + require.ErrorContains(t, err, "X-Forwarded-For") + }) + + t.Run("X-Forwarded-For is rejected regardless of case", func(t *testing.T) { + c := caddy.NewTestController("dns", `acme libp2p.direct { + registration-domain registration.libp2p.direct + client-ip-header x-forwarded-for + }`) + _, _, err := parse(c) + require.Error(t, err) + }) +} diff --git a/acme/verify_v2.go b/acme/verify_v2.go new file mode 100644 index 0000000..345ba9c --- /dev/null +++ b/acme/verify_v2.go @@ -0,0 +1,180 @@ +package acme + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + + "github.com/dunglas/httpsfv" + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/yaronf/httpsign" +) + +// v2Verified is the authenticated result of a valid registration request. +type v2Verified struct { + peerID peer.ID + keyID string // the did:key that signed + pub ed25519.PublicKey // the key inside keyID, decoded once +} + +// errMalformed marks a request that does not conform to the /v2 signing +// profile (unparseable or missing signature material), as opposed to one that +// fails authentication. The handler maps it to 400 instead of 401. +var errMalformed = errors.New("malformed request") + +// verifyV2Request checks the RFC 9421 signature (via yaronf/httpsign) against +// the fixed /v2 profile: the covered components, the RFC 9530 Content-Digest +// over body, the freshness window, the tag, and that @authority is the +// registration domain. Identity is taken from the signature's keyid, which +// carries the public key, so the body has nothing to spoof. +func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, error) { + if err := enforceV2Envelope(r); err != nil { + return nil, err + } + // Read the keyid before verification so we can resolve the key it names. + details, err := httpsign.RequestDetails(httpsig.SigLabel, r) + if err != nil { + return nil, fmt.Errorf("%w: parsing signature: %w", errMalformed, err) + } + if details.KeyID == nil { + return nil, fmt.Errorf("%w: signature is missing a keyid", errMalformed) + } + // The key in keyid decides the algorithm; a present alg must agree. + if details.Alg != "" && details.Alg != "ed25519" { + return nil, fmt.Errorf("%w: alg %q does not match the key type", errMalformed, details.Alg) + } + if details.CustomParams != nil { + return nil, fmt.Errorf("%w: unknown signature parameters", errMalformed) + } + // The nonce is required so every signature is unique. The server does not + // track nonces; replay is bounded by the expires window instead. + if details.Nonce == nil { + return nil, fmt.Errorf("%w: signature is missing a nonce", errMalformed) + } + nonceBytes, err := base64.RawURLEncoding.DecodeString(*details.Nonce) + if err != nil { + return nil, fmt.Errorf("%w: nonce is not unpadded base64url", errMalformed) + } + if len(nonceBytes) < httpsig.MinNonceBytes { + return nil, fmt.Errorf("%w: nonce carries fewer than %d random bytes", errMalformed, httpsig.MinNonceBytes) + } + // The library treats expires as optional and only bounds created, so the + // profile's "expires present, expires-created <= MaxSignatureLifetime" is + // enforced here. These are unauthenticated parses at this point, but they + // are covered by the signature verified below, so a reject is safe and a + // pass is re-checked by the verifier's own policy. + if details.Created == nil { + return nil, fmt.Errorf("%w: signature is missing a created parameter", errMalformed) + } + if details.Expires == nil { + return nil, fmt.Errorf("%w: signature is missing an expires parameter", errMalformed) + } + if details.Expires.Sub(*details.Created) > httpsig.MaxSignatureLifetime { + return nil, fmt.Errorf("%w: expires-created exceeds %s", errMalformed, httpsig.MaxSignatureLifetime) + } + regPub, err := httpsig.DecodeDIDKey(*details.KeyID) + if err != nil { + return nil, fmt.Errorf("%w: %w", errMalformed, err) + } + raw, err := regPub.Raw() + if err != nil { + return nil, fmt.Errorf("%w: reading key: %w", errMalformed, err) + } + + // The Content-Digest must match the body we actually read. Both a + // malformed header and a mismatch are the request contradicting itself, + // knowable without any key material, so they class as malformed (400) + // rather than as an authentication failure. + digestBody := io.NopCloser(bytes.NewReader(body)) + if err := httpsign.ValidateContentDigestHeader(r.Header.Values("Content-Digest"), &digestBody, []string{httpsign.DigestSha256}); err != nil { + return nil, fmt.Errorf("%w: content-digest: %w", errMalformed, err) + } + + // Verify the signature. The verifier requires every covered component, so a + // caller cannot drop one; the tag and freshness window are enforced too. + // expires decides freshness: it must be present, at most created+300s + // (both checked above), and not yet passed (SetRejectExpired). The + // SetNotOlderThan bound never rejects anything on its own, because a + // created that old always comes with an already-passed expires; it is set + // only because the library needs a value when verifyCreated is on. + cfg := httpsign.NewVerifyConfig(). + SetVerifyCreated(true). + SetNotNewerThan(httpsig.MaxForwardDrift). + SetNotOlderThan(httpsig.MaxSignatureLifetime + httpsig.MaxForwardDrift). + SetRejectExpired(true). + SetAllowedTags([]string{httpsig.RegistrationTag}) + verifier, err := httpsign.NewEd25519Verifier(ed25519.PublicKey(raw), cfg, httpsign.Headers(httpsig.RegistrationComponents...)) + if err != nil { + return nil, fmt.Errorf("building verifier: %w", err) + } + if err := httpsign.VerifyRequest(httpsig.SigLabel, *verifier, r); err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + + // @authority is covered by the signature; it must be our registration domain. + if httpsig.CanonicalAuthority(r.Host) != httpsig.CanonicalAuthority(domain) { + return nil, fmt.Errorf("unexpected authority %q", r.Host) + } + + peerID, err := peer.IDFromPublicKey(regPub) + if err != nil { + return nil, fmt.Errorf("deriving peer ID: %w", err) + } + return &v2Verified{peerID: peerID, keyID: *details.KeyID, pub: ed25519.PublicKey(raw)}, nil +} + +// enforceV2Envelope rejects signature headers that stray from the closed /v2 +// grammar before any cryptography runs: exactly one signature, labeled sig1, +// covering exactly the profile components in order, with no per-component +// parameters. The library alone would accept any label, extra components, and +// any order. +func enforceV2Envelope(r *http.Request) error { + coverage, err := singleSigMember(r, "Signature-Input") + if err != nil { + return err + } + if _, err := singleSigMember(r, "Signature"); err != nil { + return err + } + + list, ok := coverage.(httpsfv.InnerList) + if !ok { + return fmt.Errorf("%w: Signature-Input %q is not a component list", errMalformed, httpsig.SigLabel) + } + if len(list.Items) != len(httpsig.RegistrationComponents) { + return fmt.Errorf("%w: signature must cover exactly %v", errMalformed, httpsig.RegistrationComponents) + } + for i, item := range list.Items { + name, ok := item.Value.(string) + if !ok || name != httpsig.RegistrationComponents[i] { + return fmt.Errorf("%w: signature must cover exactly %v, in this order", errMalformed, httpsig.RegistrationComponents) + } + if item.Params != nil && len(item.Params.Names()) > 0 { + return fmt.Errorf("%w: covered component %q must not carry parameters", errMalformed, name) + } + } + return nil +} + +// singleSigMember parses the named header as an RFC 8941 dictionary and +// returns its only member, which must be labeled sig1. +func singleSigMember(r *http.Request, header string) (httpsfv.Member, error) { + values := r.Header.Values(header) + if len(values) != 1 { + return nil, fmt.Errorf("%w: expected exactly one %s header", errMalformed, header) + } + dict, err := httpsfv.UnmarshalDictionary(values) + if err != nil { + return nil, fmt.Errorf("%w: parsing %s: %w", errMalformed, header, err) + } + if names := dict.Names(); len(names) != 1 || names[0] != httpsig.SigLabel { + return nil, fmt.Errorf("%w: %s must carry exactly one signature, labeled %q", errMalformed, header, httpsig.SigLabel) + } + member, _ := dict.Get(httpsig.SigLabel) + return member, nil +} diff --git a/acme/writer.go b/acme/writer.go index 8357ec9..07b4549 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" + "crypto/subtle" "crypto/tls" "encoding/base64" "encoding/json" @@ -31,11 +33,9 @@ import ( "github.com/caddyserver/certmagic" "github.com/ipfs/go-datastore" - "github.com/libp2p/go-libp2p" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" httppeeridauth "github.com/libp2p/go-libp2p/p2p/http/auth" - "github.com/multiformats/go-multiaddr" ) var log = clog.NewWithPlugin(pluginName) @@ -47,8 +47,20 @@ const healthcheckApiPath = "/v1/health" type acmeWriter struct { Addr string Domain string + ForgeDomain string ExternalTLS bool + // AllowPrivateAddrs disables every reachability safeguard: destination-IP + // vetting, the address caps, the dialback IP pinning, and the probe and + // overall verification timeouts. Off by default; intended for tests and + // private deployments that trust the submitted addresses. + AllowPrivateAddrs bool + + // ClientIPHeader, when set, names the request header the fronting proxy + // populates with the real client IP (e.g. CF-Connecting-IP). Empty means + // only the direct connection address is trusted. + ClientIPHeader string + Datastore datastore.TTLDatastore ln net.Listener @@ -115,7 +127,7 @@ func (c *acmeWriter) OnStartup() error { } if c.forgeAuthKey != "" { auth := r.Header.Get(client.ForgeAuthHeader) - if c.forgeAuthKey != auth { + if !constantTimeEqual(auth, c.forgeAuthKey) { w.WriteHeader(http.StatusForbidden) fmt.Fprintf(w, "403 Forbidden: Missing %s header.", client.ForgeAuthHeader) return @@ -154,14 +166,14 @@ func (c *acmeWriter) OnStartup() error { } // Check denylist before attempting to connect - if blocked, reason := checkDenylist(clientIPs(r), typedBody.Addresses); blocked { + if blocked, reason := checkDenylist(clientIPs(r, c.ClientIPHeader), typedBody.Addresses); blocked { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(fmt.Sprintf("403 Forbidden: %s", reason))) return } httpUserAgent := r.Header.Get("User-Agent") - if err := testAddresses(r.Context(), peerID, typedBody.Addresses, httpUserAgent); err != nil { + if err := c.testAddresses(r.Context(), peerID, typedBody.Addresses, httpUserAgent); err != nil { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(fmt.Sprintf("error testing addresses: %s", err))) return @@ -210,6 +222,12 @@ func (c *acmeWriter) OnStartup() error { w.WriteHeader(http.StatusNoContent) }) + // v2 registration API: RFC 9421-signed, no libp2p PeerID-auth handshake. + mux.Handle("POST "+registrationV2ApiPath, std.Handler(registrationV2ApiPath, httpMetricsMiddleware, http.HandlerFunc(c.handleV2Challenge))) + mux.HandleFunc("GET "+healthV2ApiPath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + c.handler = withRequestLogger(mux) go func() { @@ -233,43 +251,6 @@ func withRequestLogger(next http.Handler) http.Handler { }) } -func testAddresses(ctx context.Context, p peer.ID, addrs []string, httpUserAgent string) error { - agentVersion := agentType(httpUserAgent) - h, err := libp2p.New(libp2p.NoListenAddrs, libp2p.DisableRelay()) - if err != nil { - recordPeerProbe("error", agentVersion) - return err - } - defer h.Close() - - var mas []multiaddr.Multiaddr - for _, addr := range addrs { - ma, err := multiaddr.NewMultiaddr(addr) - if err != nil { - recordPeerProbe("error", agentVersion) - return err - } - mas = append(mas, ma) - } - - err = h.Connect(ctx, peer.AddrInfo{ID: p, Addrs: mas}) - if err != nil { - recordPeerProbe("error", agentVersion) - return err - } - - // TODO: Do we need to listen on the identify event instead? - if v, err := h.Peerstore().Get(p, "AgentVersion"); err == nil { - if vs, ok := v.(string); ok { - // if we had successful libp2p identify we prefer agentVersion from it - agentVersion = vs - } - } - log.Debugf("connected to peer %s - UserAgent: %q", p, agentVersion) - recordPeerProbe("ok", agentType(agentVersion)) - return nil -} - // agentType returns bound cardinality agent label for metrics. // libp2p clients can set agent version to arbitrary strings, // and the metric labels have to have a bound cardinality @@ -299,21 +280,36 @@ func agentType(agentVersion string) string { return "other" } +// constantTimeEqual compares two secrets without leaking where they differ, +// hashing first so a length difference leaks nothing either. +func constantTimeEqual(a, b string) bool { + ha := sha256.Sum256([]byte(a)) + hb := sha256.Sum256([]byte(b)) + return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1 +} + type requestBody struct { Value string `json:"value"` Addresses []string `json:"addresses"` } -// checkDenylist checks client IPs and multiaddr IPs against denylist. -// Returns (blocked, reason) where reason describes which IP was blocked. -// Blocks if ANY IP is denied. +// checkDenylist checks the client IPs and the submitted multiaddr IPs against +// the denylist, blocking if any is denied. Returns (blocked, reason). func checkDenylist(clientIPs []netip.Addr, multiaddrs []string) (bool, string) { + if blocked, reason := denylistClientIPs(clientIPs); blocked { + return true, reason + } + return denylistAddresses(multiaddrs) +} + +// denylistClientIPs checks only the client IPs (the trusted header and the +// direct connection address), so a denylisted caller can be rejected before +// any signature or dialback work. +func denylistClientIPs(clientIPs []netip.Addr) (bool, string) { mgr := denylist.GetManager() if mgr == nil { return false, "" } - - // Check all client IPs (XFF and RemoteAddr) for _, client := range clientIPs { if !client.IsValid() { continue @@ -322,14 +318,21 @@ func checkDenylist(clientIPs []netip.Addr, multiaddrs []string) (bool, string) { return true, fmt.Sprintf("client IP %s blocked by %s", client, result.Name) } } + return false, "" +} - // Check multiaddr IPs +// denylistAddresses checks the IPs carried in the submitted multiaddrs. It does +// not see behind a /dns name; testAddresses re-checks resolved IPs. +func denylistAddresses(multiaddrs []string) (bool, string) { + mgr := denylist.GetManager() + if mgr == nil { + return false, "" + } for _, ip := range multiaddrsToIPs(multiaddrs) { if denied, result := mgr.Check(ip); denied { return true, fmt.Sprintf("multiaddr IP %s blocked by %s", ip, result.Name) } } - return false, "" } diff --git a/acme/writer_v2.go b/acme/writer_v2.go new file mode 100644 index 0000000..5fa065c --- /dev/null +++ b/acme/writer_v2.go @@ -0,0 +1,218 @@ +package acme + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "time" + + "github.com/ipfs/go-datastore" + "github.com/ipshipyard/p2p-forge/client" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/multiformats/go-multibase" +) + +// v2 registration API paths, mounted beside the unchanged v1 endpoints. +const ( + registrationV2ApiPath = "/v2/_acme-challenge" + healthV2ApiPath = "/v2/health" +) + +// maxV2BodySize bounds the registration request body. +const maxV2BodySize = 8 << 10 // 8 KiB + +// challengeTTL is how long the forge retains a submitted DNS-01 challenge +// value before it expires. Reported to the client as expiresIn. +const challengeTTL = time.Hour + +// handleV2Challenge verifies an RFC 9421-signed registration and, on success, +// stores the DNS-01 TXT value for the peer derived from the signing key. It +// replaces the v1 libp2p PeerID-auth handshake with a single signed request. +func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { + // The signature binds @authority to the registration domain. Without a + // configured domain that binding is empty and would match any request, so + // refuse to serve v2 rather than fail open. + if c.Domain == "" { + writeProblem(w, http.StatusInternalServerError, "misconfigured", "registration domain is not configured") + log.Error("v2: registration-domain is not configured; refusing v2 registration") + return + } + // Closed grammar: the signature does not cover the query string, so reject + // any request that carries one. + if r.URL.RawQuery != "" { + writeProblem(w, http.StatusBadRequest, "unexpected-query", "query strings are not allowed") + return + } + // Cheapest gate first: the optional Forge-Authorization access token. Not + // part of the /v2 spec: an extra this implementation supports so an + // operator can run a limited rollout or a test instance before opening it + // up. See client.ForgeAuthHeader. The response therefore carries no + // spec-defined problem type. + if c.forgeAuthKey != "" && !constantTimeEqual(r.Header.Get(client.ForgeAuthHeader), c.forgeAuthKey) { + writeProblem(w, http.StatusForbidden, "", fmt.Sprintf("missing or invalid %s header", client.ForgeAuthHeader)) + return + } + + // Reject a denylisted caller before any signature or dialback work. The + // client IP needs neither the body nor the signature, and anyone can mint + // a key, so nothing is leaked by answering here. + if blocked, reason := denylistClientIPs(clientIPs(r, c.ClientIPHeader)); blocked { + writeProblem(w, http.StatusForbidden, "denylisted", reason) + return + } + + // The signature covers the Content-Type header; this pins its value. + if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" { + writeProblem(w, http.StatusBadRequest, "malformed-body", "Content-Type must be application/json") + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxV2BodySize)) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + writeProblem(w, http.StatusRequestEntityTooLarge, "body-too-large", "request body exceeds limit") + } else { + writeProblem(w, http.StatusBadRequest, "malformed-body", "error reading request body") + } + return + } + + // Verify the signature, digest, freshness window, and authority. Identity is + // derived from the signing key in keyid, never from the body. A request + // that does not conform to the profile grammar is 400; one that conforms + // but fails authentication is 401. + verified, err := verifyV2Request(r, body, c.Domain) + if err != nil { + if errors.Is(err, errMalformed) { + writeProblem(w, http.StatusBadRequest, "malformed-signature", err.Error()) + } else { + writeProblem(w, http.StatusUnauthorized, "signature-invalid", err.Error()) + } + return + } + peerID := verified.peerID + + typedBody, err := decodeV2Body(body) + if err != nil { + writeProblem(w, http.StatusBadRequest, "malformed-body", err.Error()) + return + } + if err := validateChallengeValue(typedBody.Value); err != nil { + writeProblem(w, http.StatusBadRequest, "malformed-value", err.Error()) + return + } + + if blocked, reason := denylistAddresses(typedBody.Addresses); blocked { + writeProblem(w, http.StatusForbidden, "denylisted", reason) + return + } + + // Prove the key controls a real, reachable endpoint: the http-ownership + // proof (no libp2p) when an http(s) address is given, else the libp2p + // dialback. + mode, err := c.verifyReachable(r.Context(), verified, typedBody.Addresses, r.Header.Get("User-Agent")) + if err != nil { + log.Debugf("v2: address verification failed for %s: %v", peerID, err) + writeProblem(w, http.StatusUnprocessableEntity, "verification-failed", "no submitted address could be verified") + return + } + + if err := c.Datastore.PutWithTTL(r.Context(), datastore.NewKey(peerID.String()), []byte(typedBody.Value), challengeTTL); err != nil { + writeProblem(w, http.StatusInternalServerError, "storage-error", "failed to store challenge") + log.Errorf("v2: storing challenge for %s: %v", peerID, err) + return + } + + writeJSON(w, http.StatusOK, v2Response{ + DID: verified.keyID, + Name: certWildcard(peerID, c.ForgeDomain), + Verification: mode, + ExpiresIn: int(challengeTTL / time.Second), + }) +} + +// v2Response is the informational 200 body. A client needs none of it to +// proceed; it is there for humans and for a client that wants to confirm what +// the forge recorded. +type v2Response struct { + // DID is the did:key that registered (libp2p-agnostic; no raw peerid). + DID string `json:"did"` + // Name is the wildcard cert name; peerid-b36 belongs to the DNS/cert layer. + Name string `json:"name"` + // Verification is the mode that proved reachability: "http-ownership" or + // "libp2p-dialback". + Verification string `json:"verification"` + // ExpiresIn is how many seconds from now the forge keeps the submitted + // challenge value before it expires (not the DNS TTL of the TXT record). + ExpiresIn int `json:"expiresIn"` +} + +// decodeV2Body parses the registration body, rejecting unknown fields and any +// trailing data. +func decodeV2Body(body []byte) (*requestBody, error) { + tb := &requestBody{} + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(tb); err != nil { + return nil, fmt.Errorf("decoding body: %w", err) + } + if dec.More() { + return nil, fmt.Errorf("unexpected trailing data after JSON body") + } + return tb, nil +} + +// validateChallengeValue enforces the RFC 8555 §8.4 shape: base64url of a +// 32-byte SHA-256 digest, no padding. +func validateChallengeValue(value string) error { + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return fmt.Errorf("value is not unpadded base64url: %w", err) + } + if len(decoded) != 32 { + return fmt.Errorf("value is not a base64url of a SHA-256 digest") + } + return nil +} + +// certWildcard returns the wildcard cert name for a peer, e.g. +// "*..libp2p.direct". peerid-b36 lives only in this DNS/cert layer, +// never in the v2 request or proof. +func certWildcard(id peer.ID, forgeDomain string) string { + b36 := peer.ToCid(id).Encode(multibase.MustNewEncoder(multibase.Base36)) + return fmt.Sprintf("*.%s.%s", b36, forgeDomain) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// problemTypeBase prefixes every problem+json "type" URI. The fragment is the +// type slug; the Errors section of the linked document lists them all. +const problemTypeBase = "https://github.com/ipshipyard/p2p-forge/blob/main/docs/registration-v2.md#" + +// writeProblem emits an RFC 9457 problem+json response. An empty problemType +// becomes the generic "about:blank": the status code alone describes the +// problem. Used for responses that are not part of the /v2 spec. +func writeProblem(w http.ResponseWriter, status int, problemType, detail string) { + typeURI := "about:blank" + if problemType != "" { + typeURI = problemTypeBase + problemType + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": typeURI, + "title": http.StatusText(status), + "status": status, + "detail": detail, + }) +} diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go new file mode 100644 index 0000000..838dfb9 --- /dev/null +++ b/acme/writer_v2_test.go @@ -0,0 +1,407 @@ +package acme + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ipfs/go-datastore" + "github.com/ipshipyard/p2p-forge/client" + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/stretchr/testify/require" + "github.com/yaronf/httpsign" +) + +const v2TestDomain = "registration.example" + +// ttlDatastore adapts an in-memory datastore to the TTLDatastore interface the +// writer needs; TTL is irrelevant for these tests. +type ttlDatastore struct { + datastore.Datastore +} + +func (t ttlDatastore) PutWithTTL(ctx context.Context, k datastore.Key, v []byte, _ time.Duration) error { + return t.Put(ctx, k, v) +} +func (ttlDatastore) SetTTL(context.Context, datastore.Key, time.Duration) error { return nil } +func (ttlDatastore) GetExpiration(context.Context, datastore.Key) (time.Time, error) { + return time.Time{}, nil +} + +// newRegistrantHost starts a loopback libp2p host whose identity is the Ed25519 +// key returned, so the dialback in testAddresses can authenticate it. +func newRegistrantHost(t *testing.T) (host.Host, crypto.PrivKey) { + t.Helper() + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + h, err := libp2p.New(libp2p.Identity(priv), libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + require.NoError(t, err) + t.Cleanup(func() { _ = h.Close() }) + return h, priv +} + +func signedV2Request(t *testing.T, priv crypto.PrivKey, value string, addrs []string) *http.Request { + return signedV2RequestOpts(t, priv, value, addrs, v2SignOpts{}) +} + +// v2SignOpts tweaks how signedV2RequestOpts signs so tests can produce +// requests that violate the profile; the zero value is fully conforming. +type v2SignOpts struct { + omitCreated bool + omitExpires bool + expiresIn time.Duration // 0 means httpsig.MaxSignatureLifetime + nonce string // "" means a fresh 22-character nonce + keyID string // "" means the did:key of the signing key + components []string // nil means httpsig.RegistrationComponents +} + +func signedV2RequestOpts(t *testing.T, priv crypto.PrivKey, value string, addrs []string, opts v2SignOpts) *http.Request { + t.Helper() + body, err := json.Marshal(map[string]any{"value": value, "addresses": addrs}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", bytes.NewReader(body)) + req.Host = v2TestDomain + req.Header.Set("Content-Type", "application/json") + + raw, err := priv.Raw() + require.NoError(t, err) + keyID := opts.keyID + if keyID == "" { + keyID, err = httpsig.EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + } + + digestBody := io.NopCloser(bytes.NewReader(body)) + cd, err := httpsign.GenerateContentDigestHeader(&digestBody, []string{httpsign.DigestSha256}) + require.NoError(t, err) + req.Header.Set("Content-Digest", cd) + + nonce := opts.nonce + if nonce == "" { + nb := make([]byte, 16) + _, err = rand.Read(nb) + require.NoError(t, err) + nonce = base64.RawURLEncoding.EncodeToString(nb) + } + cfg := httpsign.NewSignConfig().SignCreated(!opts.omitCreated). + SetNonce(nonce). + SetKeyID(keyID). + SetTag(httpsig.RegistrationTag) + if !opts.omitExpires { + expiresIn := opts.expiresIn + if expiresIn == 0 { + expiresIn = httpsig.MaxSignatureLifetime + } + cfg = cfg.SetExpires(time.Now().Add(expiresIn).Unix()) + } + components := opts.components + if components == nil { + components = httpsig.RegistrationComponents + } + signer, err := httpsign.NewEd25519Signer(ed25519.PrivateKey(raw), cfg, httpsign.Headers(components...)) + require.NoError(t, err) + sigInput, sig, err := httpsign.SignRequest(httpsig.SigLabel, *signer, req) + require.NoError(t, err) + req.Header.Set("Signature-Input", sigInput) + req.Header.Set("Signature", sig) + return req +} + +func addrStrings(h host.Host) []string { + out := make([]string, 0, len(h.Addrs())) + for _, a := range h.Addrs() { + out = append(out, a.String()) + } + return out +} + +func newTestWriter() *acmeWriter { + c := &acmeWriter{ + Domain: v2TestDomain, + ForgeDomain: "libp2p.direct", + Datastore: ttlDatastore{datastore.NewMapDatastore()}, + // Tests dial loopback hosts, which destination-IP vetting would reject. + AllowPrivateAddrs: true, + } + return c +} + +// TestV2ResubmitIsIdempotent locks in the replay stance: the server does not +// track nonces, so resubmitting a captured request within its expires window +// repeats the same registration and changes nothing. +func TestV2ResubmitIsIdempotent(t *testing.T) { + initMetrics() + h, priv := newRegistrantHost(t) + c := newTestWriter() + + value := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x05}, 32)) + signed := signedV2Request(t, priv, value, addrStrings(h)) + body, err := io.ReadAll(signed.Body) + require.NoError(t, err) + header := signed.Header.Clone() + + resubmit := func() *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", bytes.NewReader(body)) + r.Host = v2TestDomain + r.Header = header.Clone() + rec := httptest.NewRecorder() + c.handleV2Challenge(rec, r) + return rec + } + + require.Equal(t, http.StatusOK, resubmit().Code, "first submission succeeds") + require.Equal(t, http.StatusOK, resubmit().Code, "resubmission succeeds too") +} + +func TestV2ChallengeHandlerRoundTrip(t *testing.T) { + initMetrics() + h, priv := newRegistrantHost(t) + c := newTestWriter() + + value := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xab}, 32)) + req := signedV2Request(t, priv, value, addrStrings(h)) + rec := httptest.NewRecorder() + + c.handleV2Challenge(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + pid, err := peer.IDFromPublicKey(priv.GetPublic()) + require.NoError(t, err) + got, err := c.Datastore.Get(context.Background(), datastore.NewKey(pid.String())) + require.NoError(t, err) + require.Equal(t, value, string(got)) + + wantDID, err := httpsig.EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + var resp v2Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, wantDID, resp.DID) + require.Contains(t, resp.Name, ".libp2p.direct") + require.Equal(t, "libp2p-dialback", resp.Verification) +} + +func TestV2ChallengeHandlerRejects(t *testing.T) { + initMetrics() + value := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x01}, 32)) + + t.Run("tampered body", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2Request(t, priv, value, addrStrings(h)) + // Swap the body after signing: the digest no longer matches, and a + // body contradicting its own digest is a malformed request (400). + bad := []byte(`{"value":"` + value + `","addresses":[]}`) + req.Body = io.NopCloser(bytes.NewReader(bad)) + req.ContentLength = int64(len(bad)) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("missing created", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{omitCreated: true}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("missing expires", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{omitExpires: true}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("expires-created over the lifetime", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{expiresIn: httpsig.MaxSignatureLifetime + time.Minute}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("nonce below 128 bits", func(t *testing.T) { + h, priv := newRegistrantHost(t) + // 12 bytes decode fine but fall short of the 16-byte minimum. + shortNonce := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 12)) + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{nonce: shortNonce}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("nonce not base64url", func(t *testing.T) { + h, priv := newRegistrantHost(t) + // 22 characters, but outside the unpadded base64url alphabet. + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{nonce: "!!!!!!!!!!!!!!!!!!!!!!"}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("extra covered component", func(t *testing.T) { + h, priv := newRegistrantHost(t) + extra := append(append([]string{}, httpsig.RegistrationComponents...), "@scheme") + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{components: extra}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("reordered covered components", func(t *testing.T) { + h, priv := newRegistrantHost(t) + reordered := append([]string{}, httpsig.RegistrationComponents...) + reordered[0], reordered[1] = reordered[1], reordered[0] + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{components: reordered}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("missing covered component", func(t *testing.T) { + h, priv := newRegistrantHost(t) + // Dropping content-digest would unbind the body from the signature. + short := httpsig.RegistrationComponents[:len(httpsig.RegistrationComponents)-1] + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{components: short}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("second signature label", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2Request(t, priv, value, addrStrings(h)) + req.Header.Add("Signature-Input", `sig2=("@method");created=1700000000`) + req.Header.Add("Signature", "sig2=:AAAA:") + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("alg other than ed25519", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2Request(t, priv, value, addrStrings(h)) + // The test signer emits alg="ed25519"; swap the value in place. + si := strings.Replace(req.Header.Get("Signature-Input"), `alg="ed25519"`, `alg="rsa-v1_5-sha256"`, 1) + require.Contains(t, si, `alg="rsa-v1_5-sha256"`) + req.Header.Set("Signature-Input", si) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("wrong content type", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2Request(t, priv, value, addrStrings(h)) + req.Header.Set("Content-Type", "text/plain") + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), "application/json") + }) + + t.Run("expired signature", func(t *testing.T) { + h, priv := newRegistrantHost(t) + // A conforming grammar (delta under the lifetime) whose deadline has + // passed: the profile checks admit it, the verifier's clock policy + // must reject it as unauthenticated (401), not malformed. + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{expiresIn: -time.Minute}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusUnauthorized, rec.Code, rec.Body.String()) + }) + + t.Run("keyid of a different key", func(t *testing.T) { + // The security-critical property: claiming someone else's did:key + // with a signature from another key must fail verification. + h, priv := newRegistrantHost(t) + victim, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + victimDID, err := httpsig.EncodeDIDKey(victim.GetPublic()) + require.NoError(t, err) + req := signedV2RequestOpts(t, priv, value, addrStrings(h), v2SignOpts{keyID: victimDID}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusUnauthorized, rec.Code, rec.Body.String()) + }) + + t.Run("wrong forge auth token", func(t *testing.T) { + c := newTestWriter() + c.forgeAuthKey = "test-secret" + + post := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", nil) + r.Host = v2TestDomain + if token != "" { + r.Header.Set(client.ForgeAuthHeader, token) + } + rec := httptest.NewRecorder() + c.handleV2Challenge(rec, r) + return rec + } + + require.Equal(t, http.StatusForbidden, post("").Code, "missing token") + require.Equal(t, http.StatusForbidden, post("not-the-secret").Code, "wrong token") + }) + + t.Run("wrong authority", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2Request(t, priv, value, addrStrings(h)) + c := newTestWriter() + c.Domain = "other.example" + rec := httptest.NewRecorder() + c.handleV2Challenge(rec, req) + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("bad value length", func(t *testing.T) { + h, priv := newRegistrantHost(t) + short := base64.RawURLEncoding.EncodeToString([]byte("too short")) + req := signedV2Request(t, priv, short, addrStrings(h)) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("unreachable address", func(t *testing.T) { + _, priv := newRegistrantHost(t) + // A well-formed but dead address: nothing listens here. + req := signedV2Request(t, priv, value, []string{"/ip4/127.0.0.1/tcp/1"}) + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusUnprocessableEntity, rec.Code) + }) + + t.Run("empty registration domain fails closed", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2Request(t, priv, value, addrStrings(h)) + c := newTestWriter() + c.Domain = "" + rec := httptest.NewRecorder() + c.handleV2Challenge(rec, req) + require.Equal(t, http.StatusInternalServerError, rec.Code) + }) + + t.Run("query string rejected", func(t *testing.T) { + h, priv := newRegistrantHost(t) + req := signedV2Request(t, priv, value, addrStrings(h)) + req.URL.RawQuery = "foo=bar" + rec := httptest.NewRecorder() + newTestWriter().handleV2Challenge(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) +} diff --git a/client/acme.go b/client/acme.go index a6a59b6..f491e64 100644 --- a/client/acme.go +++ b/client/acme.go @@ -107,6 +107,7 @@ type P2PForgeCertMgrConfig struct { produceShortAddrs bool renewCheckInterval time.Duration registrationDelay time.Duration + registrationAPIVersion RegistrationAPIVersion } type P2PForgeCertMgrOptions func(*P2PForgeCertMgrConfig) error @@ -170,6 +171,26 @@ func WithUserAgent(userAgent string) P2PForgeCertMgrOptions { } } +// WithRegistrationAPIVersion selects the forge registration API: RegistrationV1 +// (default), RegistrationV2 (RFC 9421, Ed25519 only), or RegistrationAuto (v2 +// with fallback to v1 when the endpoint is unavailable). +// +// In this certmagic flow the node advertises its libp2p addresses, so a v2 or +// auto registration is verified by the forge's libp2p dialback. A forge that +// offers only the http-ownership proof is not reachable through this option; +// use SendChallengeV2 directly with an http(s) address to register there. +func WithRegistrationAPIVersion(v RegistrationAPIVersion) P2PForgeCertMgrOptions { + return func(config *P2PForgeCertMgrConfig) error { + switch v { + case RegistrationV1, RegistrationV2, RegistrationAuto: + config.registrationAPIVersion = v + return nil + default: + return fmt.Errorf("WithRegistrationAPIVersion: unknown version %q", v) + } + } +} + // WithHTTPClient sets the *http.Client used when talking to the forge // registration endpoint. The default is http.DefaultClient. // @@ -381,6 +402,7 @@ func NewP2PForgeCertMgr(opts ...P2PForgeCertMgrOptions) (*P2PForgeCertMgr, error httpClient: mgrCfg.httpClient, userAgent: mgrCfg.userAgent, allowPrivateForgeAddresses: mgrCfg.allowPrivateForgeAddresses, + apiVersion: mgrCfg.registrationAPIVersion, log: acmeLog.Named("dns01solver"), resolver: mgrCfg.resolver, }, @@ -642,6 +664,7 @@ type dns01P2PForgeSolver struct { httpClient *http.Client userAgent string allowPrivateForgeAddresses bool + apiVersion RegistrationAPIVersion log *zap.SugaredLogger resolver *net.Resolver } @@ -722,23 +745,47 @@ func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challe if d.httpClient != nil { sendOpts = append(sendOpts, WithChallengeHTTPClient(d.httpClient)) } - err := SendChallenge(ctx, - d.forgeRegistrationEndpoint, - h.Peerstore().PrivKey(h.ID()), - dns01value, - advertisedAddrs, - d.forgeAuth, - d.userAgent, - d.modifyForgeRequest, - sendOpts..., - ) - if err != nil { - return fmt.Errorf("p2p-forge broker registration error: %w", err) + privKey := h.Peerstore().PrivKey(h.ID()) + + sendV1 := func() error { + return SendChallenge(ctx, d.forgeRegistrationEndpoint, privKey, dns01value, + advertisedAddrs, d.forgeAuth, d.userAgent, d.modifyForgeRequest, sendOpts...) + } + sendV2 := func() error { + return SendChallengeV2(ctx, d.forgeRegistrationEndpoint, privKey, dns01value, + advertisedAddrs, d.forgeAuth, d.userAgent, d.modifyForgeRequest, sendOpts...) } + if err := d.register(isEd25519(privKey), sendV1, sendV2); err != nil { + return fmt.Errorf("p2p-forge broker registration error: %w", err) + } return nil } +// register picks the registration API per d.apiVersion and runs it. In auto +// mode it uses v2 for an Ed25519 key and falls back to v1 only when the forge +// has no v2 endpoint (ErrV2Unsupported); any other v2 error, such as a +// verification failure, is returned as is rather than retried on v1. +func (d *dns01P2PForgeSolver) register(isEd25519 bool, sendV1, sendV2 func() error) error { + switch d.apiVersion { + case RegistrationV2: + return sendV2() + case RegistrationAuto: + if !isEd25519 { + d.log.Debugw("identity key is not Ed25519, using v1 registration") + return sendV1() + } + err := sendV2() + if err == nil || !errors.Is(err, ErrV2Unsupported) { + return err + } + d.log.Infow("v2 registration unavailable, falling back to v1", "err", err) + return sendV1() + default: + return sendV1() + } +} + func (d *dns01P2PForgeSolver) CleanUp(ctx context.Context, challenge acme.Challenge) error { // TODO: Should we implement this, or is doing delete and Last-Writer-Wins enough? return nil diff --git a/client/challenge.go b/client/challenge.go index 7010bdc..d5c0470 100644 --- a/client/challenge.go +++ b/client/challenge.go @@ -3,7 +3,6 @@ package client import ( "bytes" "context" - "encoding/json" "fmt" "io" "net/http" @@ -67,18 +66,8 @@ func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, return err } - // Adjust headers if needed - if forgeAuth != "" { - req.Header.Set(ForgeAuthHeader, forgeAuth) - } - if userAgent == "" { - userAgent = defaultUserAgent - } - req.Header.Set("User-Agent", userAgent) - if modifyForgeRequest != nil { - if err := modifyForgeRequest(req); err != nil { - return err - } + if err := decorateForgeRequest(req, forgeAuth, userAgent, modifyForgeRequest); err != nil { + return err } httpClient := o.httpClient @@ -110,31 +99,48 @@ func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, if err != nil { return fmt.Errorf("libp2p HTTP ClientPeerIDAuth error at %s: %w", registrationURL, err) } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("%s error from %s: %q", resp.Status, registrationURL, respBody) + return renderChallengeError(resp, registrationURL) } return nil } +// decorateForgeRequest applies the headers common to v1 and v2 registration +// requests (the optional Forge-Authorization token and a User-Agent) and runs +// the caller's modifyForgeRequest hook. +func decorateForgeRequest(req *http.Request, forgeAuth, userAgent string, modifyForgeRequest func(*http.Request) error) error { + if forgeAuth != "" { + req.Header.Set(ForgeAuthHeader, forgeAuth) + } + if userAgent == "" { + userAgent = defaultUserAgent + } + req.Header.Set("User-Agent", userAgent) + if modifyForgeRequest != nil { + return modifyForgeRequest(req) + } + return nil +} + +// renderChallengeError builds an error from a non-200 registration response, +// reading a bounded slice of the body for context. The caller closes resp.Body. +func renderChallengeError(resp *http.Response, registrationURL string) error { + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + if err != nil { + return fmt.Errorf("%s from %s (reading error body failed: %w)", resp.Status, registrationURL, err) + } + return fmt.Errorf("%s error from %s: %q", resp.Status, registrationURL, body) +} + // ChallengeRequest creates an HTTP Request object for submitting an ACME challenge to the p2p-forge HTTP server for a given peerID. // Construction of the request requires a list of multiaddresses that the peerID is listening on using // publicly reachable IP addresses. // // Sending the request to the DNS server requires performing HTTP PeerID Authentication for the corresponding peerID func ChallengeRequest(ctx context.Context, registrationURL string, challenge string, addrs []multiaddr.Multiaddr) (*http.Request, error) { - maStrs := make([]string, len(addrs)) - for i, addr := range addrs { - maStrs[i] = addr.String() - } - - body, err := json.Marshal(&struct { - Value string `json:"value"` - Addresses []string `json:"addresses"` - }{ - Value: challenge, - Addresses: maStrs, - }) + // Shared with the v2 client so the two wire bodies cannot drift apart. + body, err := marshalChallengeBody(challenge, addrs) if err != nil { return nil, err } diff --git a/client/challenge_v2.go b/client/challenge_v2.go new file mode 100644 index 0000000..d842bf8 --- /dev/null +++ b/client/challenge_v2.go @@ -0,0 +1,174 @@ +package client + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/multiformats/go-multiaddr" + "github.com/yaronf/httpsign" +) + +// RegistrationAPIVersion selects which forge registration API the client uses. +type RegistrationAPIVersion string + +const ( + // RegistrationV1 uses the libp2p PeerID-auth handshake (/v1). + RegistrationV1 RegistrationAPIVersion = "v1" + // RegistrationV2 uses RFC 9421 request signatures (/v2). Requires an + // Ed25519 identity key. + RegistrationV2 RegistrationAPIVersion = "v2" + // RegistrationAuto tries /v2 for Ed25519 keys and falls back to /v1 when + // the endpoint is unavailable. + RegistrationAuto RegistrationAPIVersion = "auto" +) + +// ErrV2Unsupported reports that the forge does not expose the /v2 endpoint, so +// a caller may fall back to /v1. +var ErrV2Unsupported = errors.New("v2 registration endpoint not available") + +// SendChallengeV2 submits the DNS-01 challenge value to the forge /v2 endpoint, +// authenticated by a single RFC 9421 request signature over the peer's Ed25519 +// key. Unlike v1 it is one request: no PeerID-auth handshake, no cookie jar. +func SendChallengeV2(ctx context.Context, baseURL string, privKey crypto.PrivKey, challenge string, addrs []multiaddr.Multiaddr, forgeAuth string, userAgent string, modifyForgeRequest func(r *http.Request) error, opts ...SendChallengeOption) error { + o := sendChallengeOptions{} + for _, opt := range opts { + if err := opt(&o); err != nil { + return err + } + } + + registrationURL := fmt.Sprintf("%s/v2/_acme-challenge", baseURL) + body, err := marshalChallengeBody(challenge, addrs) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("creating request to %s: %w", registrationURL, err) + } + req.Header.Set("Content-Type", "application/json") + // decorateForgeRequest runs the modifyForgeRequest hook, which may set + // req.Host (the covered @authority component), so it must run before + // signing. Content-Type is also covered and is set above. + if err := decorateForgeRequest(req, forgeAuth, userAgent, modifyForgeRequest); err != nil { + return err + } + + if err := signV2Request(req, privKey, body); err != nil { + return err + } + + httpClient := o.httpClient + if httpClient == nil { + httpClient = http.DefaultClient + } + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("sending v2 registration request to %s: %w", registrationURL, err) + } + defer resp.Body.Close() + + // A forge without /v2 answers 404 (path absent) or 405 (v1-only mux); map + // those to ErrV2Unsupported so a caller can fall back to /v1. + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed { + return fmt.Errorf("%w: %s", ErrV2Unsupported, resp.Status) + } + if resp.StatusCode != http.StatusOK { + return renderChallengeError(resp, registrationURL) + } + return nil +} + +func marshalChallengeBody(challenge string, addrs []multiaddr.Multiaddr) ([]byte, error) { + maStrs := make([]string, len(addrs)) + for i, addr := range addrs { + maStrs[i] = addr.String() + } + body, err := json.Marshal(&struct { + Value string `json:"value"` + Addresses []string `json:"addresses"` + }{ + Value: challenge, + Addresses: maStrs, + }) + if err != nil { + return nil, fmt.Errorf("marshaling challenge body: %w", err) + } + return body, nil +} + +// isEd25519 reports whether k is an Ed25519 key, the only type /v2 accepts. +// It checks the key's Type() rather than a concrete Go type, so a wrapped or +// delegated Ed25519 key is still recognized. +func isEd25519(k crypto.PrivKey) bool { + return k.Type() == crypto.Ed25519 +} + +// signV2Request signs req in place for the /v2 profile: an RFC 9421 signature +// (via yaronf/httpsign) over the fixed components, plus an RFC 9530 +// Content-Digest over body. +func signV2Request(req *http.Request, privKey crypto.PrivKey, body []byte) error { + if !isEd25519(privKey) { + return fmt.Errorf("v2 registration requires an Ed25519 key") + } + raw, err := privKey.Raw() + if err != nil { + return fmt.Errorf("reading private key: %w", err) + } + keyID, err := httpsig.EncodeDIDKey(privKey.GetPublic()) + if err != nil { + return err + } + nonce, err := generateNonce() + if err != nil { + return err + } + + digestBody := io.NopCloser(bytes.NewReader(body)) + cd, err := httpsign.GenerateContentDigestHeader(&digestBody, []string{httpsign.DigestSha256}) + if err != nil { + return fmt.Errorf("generating content-digest: %w", err) + } + req.Header.Set("Content-Digest", cd) + + // SignAlg(false) keeps the wire minimal: the key in keyid decides the + // algorithm, and the server rejects any alg other than ed25519. + cfg := httpsign.NewSignConfig(). + SignAlg(false). + SignCreated(true). + SetExpires(time.Now().Add(httpsig.MaxSignatureLifetime).Unix()). + SetNonce(nonce). + SetKeyID(keyID). + SetTag(httpsig.RegistrationTag) + signer, err := httpsign.NewEd25519Signer(ed25519.PrivateKey(raw), cfg, httpsign.Headers(httpsig.RegistrationComponents...)) + if err != nil { + return fmt.Errorf("building signer: %w", err) + } + sigInput, sig, err := httpsign.SignRequest(httpsig.SigLabel, *signer, req) + if err != nil { + return fmt.Errorf("signing v2 registration request: %w", err) + } + req.Header.Set("Signature-Input", sigInput) + req.Header.Set("Signature", sig) + return nil +} + +// generateNonce returns a fresh base64url nonce with 128 bits of entropy. +func generateNonce() (string, error) { + b := make([]byte, httpsig.MinNonceBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/client/challenge_v2_test.go b/client/challenge_v2_test.go new file mode 100644 index 0000000..32f3d60 --- /dev/null +++ b/client/challenge_v2_test.go @@ -0,0 +1,82 @@ +package client + +import ( + "bytes" + "context" + "crypto/rand" + "io" + "net/http" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" +) + +func TestIsEd25519(t *testing.T) { + ed, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + require.True(t, isEd25519(ed)) + + secp, _, err := crypto.GenerateSecp256k1Key(rand.Reader) + require.NoError(t, err) + require.False(t, isEd25519(secp)) +} + +// respondingClient returns an *http.Client whose transport answers every +// request with the given status and body. +func respondingClient(status int, body string) *http.Client { + return &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Body: io.NopCloser(bytes.NewReader([]byte(body))), + Header: make(http.Header), + Request: req, + }, nil + }), + } +} + +func TestSendChallengeV2StatusMapping(t *testing.T) { + sk, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + + send := func(status int, body string) error { + return SendChallengeV2( + context.Background(), + "http://forge.example.invalid", + sk, "test-challenge-value", nil, + "", "", nil, + WithChallengeHTTPClient(respondingClient(status, body)), + ) + } + + t.Run("200 succeeds", func(t *testing.T) { + require.NoError(t, send(http.StatusOK, `{"did":"did:key:z"}`)) + }) + t.Run("404 maps to ErrV2Unsupported", func(t *testing.T) { + require.ErrorIs(t, send(http.StatusNotFound, "not found"), ErrV2Unsupported) + }) + t.Run("405 maps to ErrV2Unsupported", func(t *testing.T) { + require.ErrorIs(t, send(http.StatusMethodNotAllowed, "nope"), ErrV2Unsupported) + }) + t.Run("422 surfaces the body, not ErrV2Unsupported", func(t *testing.T) { + err := send(http.StatusUnprocessableEntity, "no address verified") + require.Error(t, err) + require.NotErrorIs(t, err, ErrV2Unsupported) + require.ErrorContains(t, err, "no address verified") + }) +} + +func TestSendChallengeV2RejectsNonEd25519(t *testing.T) { + secp, _, err := crypto.GenerateSecp256k1Key(rand.Reader) + require.NoError(t, err) + err = SendChallengeV2( + context.Background(), + "http://forge.example.invalid", + secp, "test-challenge-value", nil, + "", "", nil, + ) + require.ErrorContains(t, err, "Ed25519") +} diff --git a/client/defaults.go b/client/defaults.go index d8261ca..7527c65 100644 --- a/client/defaults.go +++ b/client/defaults.go @@ -17,8 +17,10 @@ const ( // secret that limits access to registration endpoint ForgeAuthEnv = "FORGE_ACCESS_TOKEN" - // ForgeAuthHeader optional HTTP header that client should include when - // talking to a limited access registration endpoint + // ForgeAuthHeader carries the optional access token for the registration + // endpoints. It is not part of the registration API spec: it is an extra + // header the p2p-forge implementation supports so an operator can run a + // limited rollout or a test instance before opening it up fully. ForgeAuthHeader = "Forge-Authorization" DefaultStorageLocation = "p2p-forge-certs" diff --git a/client/ownership.go b/client/ownership.go new file mode 100644 index 0000000..cb6160a --- /dev/null +++ b/client/ownership.go @@ -0,0 +1,154 @@ +package client + +import ( + "crypto/ed25519" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "sync" + "time" + + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/ipshipyard/p2p-forge/internal/ownership" + "github.com/libp2p/go-libp2p/core/crypto" +) + +// WellKnownProofPath is the path prefix where a node serves its ownership +// proof; the full path is this prefix followed by the node's did:key. The path +// is keyed by did:key (not a peerid) to keep the v2 surface libp2p-agnostic. +const WellKnownProofPath = "/.well-known/p2p-forge/" + +// HTTPOrigin is the canonical form of an http(s) endpoint used by the ownership +// proof: an origin string the proof binds, plus the pieces needed to connect. +type HTTPOrigin struct { + Origin string // scheme://host:port, lowercase host, explicit port + Scheme string + Host string + Port string +} + +// CanonicalOrigin parses an origin-only http(s) URL into its canonical form. +// It rejects userinfo, a path, query, or fragment so the signed origin is +// unambiguous and cannot be widened by trailing URL components. An IP-literal +// host is normalized to its canonical textual form, and an IPv6 literal is +// bracketed in Origin ("https://[2001:db8::1]:443"), so both sides of the +// proof derive the same origin string and the string stays valid in a URL. +func CanonicalOrigin(rawURL string) (HTTPOrigin, error) { + u, err := url.Parse(rawURL) + if err != nil { + return HTTPOrigin{}, fmt.Errorf("parsing origin URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return HTTPOrigin{}, fmt.Errorf("origin scheme must be http or https, got %q", u.Scheme) + } + if u.User != nil { + return HTTPOrigin{}, fmt.Errorf("origin must not contain userinfo") + } + if u.Path != "" && u.Path != "/" { + return HTTPOrigin{}, fmt.Errorf("origin must not contain a path") + } + if u.RawQuery != "" || u.Fragment != "" { + return HTTPOrigin{}, fmt.Errorf("origin must not contain a query or fragment") + } + host := strings.ToLower(u.Hostname()) + if host == "" { + return HTTPOrigin{}, fmt.Errorf("origin must contain a host") + } + if ip, err := netip.ParseAddr(host); err == nil { + // A zone (fe80::1%eth0) names an interface on one host: it can never + // be a publicly verifiable origin, and its "%" is not URL-safe. + if ip.Zone() != "" { + return HTTPOrigin{}, fmt.Errorf("origin must not contain an IPv6 zone") + } + host = ip.Unmap().String() + } + port := u.Port() + if port == "" { + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + return HTTPOrigin{ + Origin: u.Scheme + "://" + net.JoinHostPort(host, port), + Scheme: u.Scheme, + Host: host, + Port: port, + }, nil +} + +// OwnershipProofHandler returns an http.Handler that serves the node's ownership +// proof (a compact EdDSA JWT) at WellKnownProofPath+ for the given +// origin. Mount it on the node's existing HTTP server. The proof is cached and +// refreshed before it expires, so it stays cacheable and the key is used rarely. +// +// rawURL is the public origin the node is registering (e.g. https://gw.example). +func OwnershipProofHandler(privKey crypto.PrivKey, rawURL string) (http.Handler, error) { + o, err := CanonicalOrigin(rawURL) + if err != nil { + return nil, err + } + did, err := httpsig.EncodeDIDKey(privKey.GetPublic()) // also rejects non-Ed25519 keys + if err != nil { + return nil, err + } + raw, err := privKey.Raw() + if err != nil { + return nil, fmt.Errorf("reading private key: %w", err) + } + signer := &ownershipSigner{priv: ed25519.PrivateKey(raw), origin: o.Origin} + wantPath := WellKnownProofPath + did + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != wantPath { + http.NotFound(w, r) + return + } + proof, err := signer.token(time.Now()) + if err != nil { + http.Error(w, "could not produce ownership proof", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/jwt") + if r.Method == http.MethodHead { + return + } + _, _ = io.WriteString(w, proof) + }), nil +} + +// ownershipSigner caches a signed proof JWT and re-signs it before expiry. +type ownershipSigner struct { + priv ed25519.PrivateKey + origin string + + mu sync.Mutex + cached string + expires time.Time +} + +func (s *ownershipSigner) token(now time.Time) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Refresh once we are into the last quarter of the window. + if s.cached == "" || now.After(s.expires.Add(-ownership.DefaultWindow/4)) { + tok, err := ownership.Sign(s.priv, s.origin, now, ownership.DefaultWindow) + if err != nil { + return "", err + } + s.cached = tok + s.expires = now.Add(ownership.DefaultWindow) + } + return s.cached, nil +} diff --git a/client/solver_test.go b/client/solver_test.go new file mode 100644 index 0000000..17e5506 --- /dev/null +++ b/client/solver_test.go @@ -0,0 +1,62 @@ +package client + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// TestSolverRegisterDispatch covers the version selection and the auto-mode +// v2->v1 fallback rule without any network: sendV1/sendV2 just record calls. +func TestSolverRegisterDispatch(t *testing.T) { + verifyFailed := errors.New("no address verified") + + type call struct{ v1, v2 bool } + run := func(version RegistrationAPIVersion, isEd bool, v2err error) (call, error) { + var c call + s := &dns01P2PForgeSolver{apiVersion: version, log: zap.NewNop().Sugar()} + err := s.register(isEd, + func() error { c.v1 = true; return nil }, + func() error { c.v2 = true; return v2err }, + ) + return c, err + } + + t.Run("explicit v1 uses v1", func(t *testing.T) { + c, err := run(RegistrationV1, true, nil) + require.NoError(t, err) + require.Equal(t, call{v1: true}, c) + }) + + t.Run("explicit v2 uses v2, no fallback", func(t *testing.T) { + c, err := run(RegistrationV2, true, ErrV2Unsupported) + require.ErrorIs(t, err, ErrV2Unsupported) + require.Equal(t, call{v2: true}, c) + }) + + t.Run("auto with Ed25519 uses v2", func(t *testing.T) { + c, err := run(RegistrationAuto, true, nil) + require.NoError(t, err) + require.Equal(t, call{v2: true}, c) + }) + + t.Run("auto falls back to v1 when v2 is unsupported", func(t *testing.T) { + c, err := run(RegistrationAuto, true, ErrV2Unsupported) + require.NoError(t, err) + require.Equal(t, call{v1: true, v2: true}, c) + }) + + t.Run("auto does not fall back on a verification failure", func(t *testing.T) { + c, err := run(RegistrationAuto, true, verifyFailed) + require.ErrorIs(t, err, verifyFailed) + require.Equal(t, call{v2: true}, c, "a non-unsupported v2 error must not retry v1") + }) + + t.Run("auto with a non-Ed25519 key uses v1", func(t *testing.T) { + c, err := run(RegistrationAuto, false, nil) + require.NoError(t, err) + require.Equal(t, call{v1: true}, c) + }) +} diff --git a/docs/key-types.md b/docs/key-types.md new file mode 100644 index 0000000..c0d73fe --- /dev/null +++ b/docs/key-types.md @@ -0,0 +1,149 @@ +# p2p-forge `/v2`: adding a key type + +`/v2` is Ed25519-only today (2026-Q3), but its request profile is built for crypto-agility +so it can adopt new key types without a breaking change. The near-term driver is +post-quantum signatures. This document explains how a new key type fits and +tracks the standards each one depends on. It builds on the +[`/v2` API reference](registration-v2.md) and uses the same requirements +language (MUST, SHOULD, MAY). + +Three things make a key type work, and each SHOULD be pinned to an existing +registry rather than invented here: + +1. **Identifier.** `keyid` is a `did:key` + ([W3C did:key spec](https://w3c-ccg.github.io/did-key-spec/)), which is + self-describing: the key's type is a + [multicodec](https://github.com/multiformats/multicodec) prefix on the raw + public key. The exact Ed25519 encoding (`0xed01` prefix, base58btc, `z` + header) is pinned by + [W3C Controlled Identifiers 1.0](https://www.w3.org/TR/cid-1.0/), a + Recommendation. Adding a type means accepting its multicodec when decoding + the `did:key`. Old clients are unaffected. A request with a key type the + server does not accept fails with a `malformed-signature` error. +2. **Algorithm.** The forge MUST derive the signature algorithm from the key's + multicodec, never from a client-supplied value. RFC 9421 allows an explicit + `alg` parameter but treats the key material as authoritative; this profile + follows that, so a present `alg` MUST match the codec-derived algorithm or the + server MUST reject the request. Prefer an algorithm already in the IANA + [HTTP Message Signature Algorithms registry](https://www.iana.org/assignments/http-message-signature/http-message-signature.xhtml) + established by RFC 9421. +3. **Signature encoding.** The bytes in the `Signature` header MUST match what a + generic RFC 9421 verifier expects for that algorithm. This is the subtle part + (see below). + +## Identifiers: on the wire versus in DNS + +Two identifiers derive from the same key, with very different size budgets: + +- The **`did:key`** in the request carries the full public key. It is bounded + only by HTTP header and JSON limits, so a multi-kilobyte post-quantum key is + fine here. +- The **DNS and cert identifier** is the libp2p peer ID as a base36 CID, used as + a single label in `_acme-challenge..` and in the wildcard + cert `*..`. A DNS label is capped at 63 octets + ([RFC 1035 section 2.3.4](https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4)), + and today's Ed25519 label is already about 62 characters, right at that limit. + +So the full public key cannot sit in the DNS label once a key is larger than +Ed25519's. libp2p already handles this: for a key above its inline threshold (42 +bytes), the peer ID uses a SHA-256 multihash of the key instead of inlining it, +so the base36 label stays a fixed ~57 characters whatever the key size. This is +exactly how an RSA peer ID looks today, and it is how an ML-DSA peer ID will +look: the CID commits to the key, and the full key is recovered from the +`did:key` in the request, not from the DNS name. + +A new key type MUST use this hash-based peer ID unless its public key is as small +as Ed25519's. Before enabling a type, an implementation MUST confirm the base36 +peer ID is at most 63 octets. + +## Classical key types + +Ed25519 is the only supported type. The rest are unsupported on `/v2` and stay +on `/v1`; the notes say what each would need before it could be enabled, and +none should be enabled until a spec pins that piece and ships a test vector. + +The multicodec registry marks all of these code points `draft` (it reserves +`permanent` for a small core set), so the values below can still change. + +| Key | Status | Multicodec | RFC 9421 algorithm | Notes | +| --- | --- | --- | --- | --- | +| Ed25519 | Supported | `0xed` | `ed25519` | libp2p signs raw 64-byte Ed25519, exactly what RFC 9421 `ed25519` expects, so it interoperates with off-the-shelf tooling. This is why it is the primary. | +| ECDSA P-256 | Unsupported | `0x1200` | `ecdsa-p256-sha256` | libp2p signs ASN.1 DER, while RFC 9421 (section 3.3) mandates the fixed-width `r \|\| s` form (as in JWS), and the multicodec is a compressed point, so both the key bytes and the signature bytes need conversion. | +| ECDSA P-384 | Unsupported | `0x1201` | `ecdsa-p384-sha384` | Same encoding gaps as P-256. | +| RSA (PKCS#1 v1.5, SHA-256) | Unsupported | `0x1205` | `rsa-v1_5-sha256` | Encoding-compatible, but not enabled. Signatures and keys are large, so mind the request size limits. | +| secp256k1 | Unsupported | `0xe7` | none | No IANA-registered RFC 9421 algorithm exists. Enabling it needs a profile-defined algorithm identifier and a decision on encoding (libp2p uses ASN.1 DER). | + +## Post-quantum signatures + +Post-quantum is why crypto-agility matters now. The near-term risk is "harvest +now, decrypt later," and a swarm needs years of lead time before it can rely on a +new key type, so opt-in support has to exist well before any forced migration. +This is tracked for the wider IPFS and libp2p stack in +[ipfs/kubo#11281](https://github.com/ipfs/kubo/issues/11281), which counts +HTTP-signature (RFC 9421) identities like this one among the pieces that need a +post-quantum path. The goal is opt-in PQ, not changing the Ed25519 default. + +The identifier and algorithm layers are already landing: + +- NIST finalized [ML-DSA (FIPS 204)](https://csrc.nist.gov/pubs/fips/204/final) + and [SLH-DSA (FIPS 205)](https://csrc.nist.gov/pubs/fips/205/final). +- [Multicodec](https://github.com/multiformats/multicodec) code points for the + public keys are registered (draft): `mldsa-{44,65,87}-pub` (`0x1210`-`0x1212`) + and `slhdsa-*-pub` (`0x1220`+). +- The `ml-dsa-44`, `ml-dsa-65`, and `ml-dsa-87` algorithm identifiers are + defined by the C2SP [`httpsig-pq`](https://c2sp.org/httpsig-pq) + specification. They are not yet in the IANA HTTP Message Signature + Algorithms registry, which currently holds only the six RFC 9421 + algorithms; httpsig-pq requests their registration. + +ML-DSA fits this profile more cleanly than ECDSA does: its signatures are raw, +fixed-size byte strings, like Ed25519, so they carry in the `Signature` header +with no DER-to-`r || s` reconciliation. Once the pieces below are in place, +enabling ML-DSA is the same three-step change as any other key type. + +| Key | Status | Multicodec | RFC 9421 algorithm | Notes | +| --- | --- | --- | --- | --- | +| ML-DSA-44 / 65 / 87 | Unsupported | `0x1210` / `0x1211` / `0x1212` (draft) | `ml-dsa-44` / `ml-dsa-65` / `ml-dsa-87` (C2SP, IANA registration pending) | Identifiers exist on both layers; blocked on libp2p key support. | +| SLH-DSA (all parameter sets) | Unsupported | `0x1220`+ (draft) | none | No RFC 9421 algorithm yet, and signatures are large (see below). | + +What is still pending, and where: + +- **libp2p key support is the gating dependency.** `keyid` is a `did:key` over the + libp2p public key, so p2p-forge can accept an ML-DSA key only once go-libp2p + produces and verifies one (or the identity layer is decoupled from the + `libp2p-key` wrapper). This is tracked in + [ipfs/kubo#11281](https://github.com/ipfs/kubo/issues/11281) and + [libp2p/specs#710](https://github.com/libp2p/specs/pull/710); p2p-forge will + follow whatever go-libp2p adopts. +- **did:key** needs its binding for these multicodecs to settle so the identifier + is unambiguous across implementations. +- **SLH-DSA** has a multicodec but no RFC 9421 algorithm, so it cannot be used on + the signature path yet. + +PQ keys and signatures are large. ML-DSA public keys run 1.3 to 2.6 KB and its +signatures 2.4 to 4.6 KB; SLH-DSA signatures reach 8 to 50 KB; Ed25519 is 32 and +64 bytes. Large public keys never enter the DNS label, because the peer ID hashes +them the same way it hashes an RSA key (see Identifiers above), but they do +travel in the `did:key`, and the `Signature` header grows with the signature. +Check the header-size limits of any fronting proxy or CDN, and the proof-fetch +header cap on the `http-ownership` path. ML-DSA is workable; SLH-DSA is likely +too large to carry in a header for now. + +## Guidance + +- Prefer a key type that has both a `did:key` multicodec and an IANA-registered + RFC 9421 algorithm whose signature encoding matches what libp2p produces. + Ed25519 is the only one that clears all three bars unchanged. +- For ECDSA, specify both conversions before enabling the type, the + compressed-point public key and the DER-to-`r \|\| s` signature, and add a + test vector, so non-Go and non-libp2p signers can interoperate. +- Do not invent an `alg` identifier when a published one fits. Adopt the + registered multicodec and the RFC 9421 algorithm identifier from IANA, or + from a public spec pending IANA registration (for example the C2SP + `ml-dsa-*` names above), rather than a local name. +- Migration is additive: a new type is opt-in, the `did:key` self-describes it, + and Ed25519 stays the default while existing clients keep working through a + dual-stack transition. +- The `http-ownership` proof needs no per-type work: it reuses the same key and + the same signature machinery, so any key type accepted for requests works for + the proof automatically. diff --git a/docs/registration-v2.md b/docs/registration-v2.md new file mode 100644 index 0000000..708aca1 --- /dev/null +++ b/docs/registration-v2.md @@ -0,0 +1,345 @@ +# p2p-forge `/v2` registration API + +The `/v2` API lets a node claim `*..libp2p.direct` and get a TLS cert +without running a libp2p client for the registration itself. Requests are signed +with [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421) +and [Digest Fields (RFC 9530)](https://www.rfc-editor.org/rfc/rfc9530), so any +HTTP client that can produce an Ed25519 signature can register. + +`/v1` (the libp2p PeerID-auth handshake) still works and is unchanged. It is +documented in the libp2p [AutoTLS client spec](https://github.com/libp2p/specs/blob/master/tls/autotls-client.md). + +This document is the reference for `/v2`. It is enough to implement a client in +any language. + +## Requirements language + +The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to +be interpreted as described in BCP 14 +([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), +[RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)) when, and only when, they +appear in all capitals. + +In this document the client is the registrant and the server is the forge. + +## What the forge guarantees + +Two independent checks gate a registration: + +1. **Key ownership.** The request signature proves the caller holds the private + key for ``. Only that key holder can set the DNS-01 record for its + own `*..libp2p.direct` name. This is the security-critical property, + and the server MUST verify the signature before it acts on the request. +2. **A real, reachable endpoint.** The caller MUST prove control of a public + endpoint, either by serving a signed proof over HTTP (below) or by answering + a libp2p dial. This is anti-abuse: it keeps the forge from minting certs for + keys that run nothing. It does not scope the cert. + +The `` 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 +name and the cert name, which `/v2` shares with `/v1`. + +## Endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/v2/_acme-challenge` | Set the DNS-01 TXT value for the peer derived from the signing key. | +| `GET` | `/v2/health` | Liveness. Always `204`. | + +## Request signing + +### Key identifier + +`keyid` MUST be a `did:key` for an Ed25519 key, for example +`did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK`. The server MUST derive +identity with `peer.IDFromPublicKey` over the key in `keyid` and MUST NOT trust +any peer field in the body. Non-Ed25519 keys are not accepted on `/v2`; those +peers use `/v1`. + +### Content-Digest (RFC 9530) + +Every request MUST carry a `Content-Digest` over the exact body bytes, using +`sha-256`. The forge MUST check the digest before it parses the body, and the +signature MUST cover the digest, so signing the request signs the body too. + +``` +Content-Digest: sha-256=:: +``` + +### Signature (RFC 9421) + +The signature MUST cover exactly this set of components, in this order, with no +per-component parameters: + +``` +("@method" "@authority" "@path" "content-type" "content-digest") +``` + +with these signature parameters: + +| Parameter | Meaning | +| --- | --- | +| `created` | Unix seconds when signed. | +| `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`. | + +The server enforces this grammar as written: it compares the covered-components +list against the exact serialization above (same set, same order, no +per-component parameters) and rejects anything else as `malformed-signature`. +An `alg` parameter is OPTIONAL, because the key in `keyid` decides the +algorithm; a present `alg` MUST be `ed25519`. Any signature parameter other +than the table above and `alg` MUST be rejected. + +`@authority` MUST equal the registration domain (for example +`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. + +The server MUST reject a `created` more than 30 seconds in the future and an +`expires` that has already passed. With the 300-second cap on +`expires - created`, this is the whole freshness policy. There is no extra +grace for client clock skew: a skewed clock shifts `created` and `expires` by +the same amount, so the `expires` check covers it. A client with a fast clock +SHOULD NOT sign `created` far ahead of real time. + +### Signature base + +The signature base is built per RFC 9421 section 2.5: one line per covered +component as `"": `, then a final `"@signature-params"` line with no +trailing newline. For a POST to `registration.libp2p.direct` it looks like: + +``` +"@method": POST +"@authority": registration.libp2p.direct +"@path": /v2/_acme-challenge +"content-type": application/json +"content-digest": sha-256=:: +"@signature-params": ("@method" "@authority" "@path" "content-type" "content-digest");created=1700000000;expires=1700000060;nonce="dGVzdG5vbmNlMTIzNDU2Nw";keyid="did:key:z6Mk...";tag="p2p-forge-reg" +``` + +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: + +``` +Signature-Input: sig1=("@method" "@authority" "@path" "content-type" "content-digest");created=1700000000;expires=1700000060;nonce="dGVzdG5vbmNlMTIzNDU2Nw";keyid="did:key:z6Mk...";tag="p2p-forge-reg" +Signature: sig1=:: +``` + +`Signature-Input` MUST be in RFC 8941 canonical form: the server rebuilds the +signature base from the canonical serialization of what it received, so a +non-canonical form verifies only if the signature was made over the canonical +form. The signature is a raw Ed25519 signature over the UTF-8 bytes of the +base. + +### Body + +```json +{ + "value": "", + "addresses": ["", "..."] +} +``` + +The request MUST carry `Content-Type: application/json` (media-type parameters +such as `charset` are ignored). The body MUST be at most 8 KiB. The server MUST +reject unknown JSON fields and trailing data. `addresses` tells the forge where +to prove reachability (below). + +### Success + +`200` with a body that is informational: a client needs none of it to proceed. + +```json +{ + "did": "did:key:z6Mk...", + "name": "*..libp2p.direct", + "verification": "http-ownership", + "expiresIn": 3600 +} +``` + +| Field | Meaning | +| --- | --- | +| `did` | The `did:key` that registered, echoed back. | +| `name` | The wildcard cert name the peer can now get. | +| `verification` | Which check passed: `http-ownership` or `libp2p-dialback`. | +| `expiresIn` | Seconds from now until the forge expires the stored challenge value. This is not the DNS TTL of the TXT record. This is how long we serve the value. | + +## Proving reachability + +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. + +A client that wants to stay off libp2p SHOULD provide at least one `http(s)` +address and serve the ownership proof. A client MAY provide libp2p multiaddrs, +but MUST NOT assume that a given server supports the dialback. + +The forge tries the addresses in the body. It verifies an `http://` or +`https://` address with the ownership proof, and treats anything else as a +libp2p multiaddr to dial (where the dialback is supported). It tries the +ownership proof first, and falls back to the dialback if that is absent or +fails. + +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. + +### http-ownership (no libp2p) + +The node MUST serve, at the key-scoped path below, a compact EdDSA JWT +([RFC 7519](https://www.rfc-editor.org/rfc/rfc7519)) proving its key controls the +origin: + +``` +GET http(s)://[:]/.well-known/p2p-forge/ +``` + +The `p2p-forge` well-known path suffix may be registered in the IANA registry +([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 +node's Ed25519 key. The node signs it once and reuses it until close to expiry, +so it is cacheable and can be signed offline. The JWT header MUST carry +`alg: EdDSA` and `typ: p2p-forge-ownership+jwt` (explicit typing per +[RFC 8725](https://www.rfc-editor.org/rfc/rfc8725), so no other JWT signed by +the same key can pass as an ownership proof). The payload carries these claims: + +| Claim | Meaning | +| --- | --- | +| `origin` | The canonical `scheme://host:port` the key controls (below). | +| `iat` | Issued-at, unix seconds. | +| `exp` | Expiry, unix seconds. `exp - iat` MUST NOT exceed 14 days, with no extra allowance. | + +The `origin` string is `scheme://host:port` built as follows: + +1. `scheme` is lowercase, `http` or `https` only. +2. There is no userinfo, path, query, or fragment; a lone trailing `/` in the + source URL is dropped. +3. `host` is lowercase. An IP-literal host is in its canonical textual form + ([RFC 5952](https://www.rfc-editor.org/rfc/rfc5952) for IPv6), an + IPv4-mapped IPv6 literal collapses to its IPv4 form, an IPv6 literal stays + bracketed, and a zoned IPv6 literal (`fe80::1%eth0`) is rejected. +4. `port` is always explicit: `443` fills in for `https` and `80` for `http` + when the URL has none. + +For example `https://GW.Example` becomes `https://gw.example:443`, and +`http://[::ffff:1.2.3.4]` becomes `http://1.2.3.4:80`. This differs from the +serialization browsers use for the `Origin` header +([RFC 6454](https://www.rfc-editor.org/rfc/rfc6454) section 6.2), which omits +a default port; the always-explicit port keeps the comparison a single string +equality with no per-scheme table. + +The forge MUST verify the JWT under the registration `keyid`, and MUST NOT trust +the key or `kid` the token itself carries. It MUST check that the `origin` claim +equals, as an exact string, the origin it connected to (scheme, host, and port +are all bound, so an `http` proof cannot satisfy an `https` address), and MUST +reject an expired token. The verifier allows 5 minutes of clock skew on `iat` +and `exp`, and a signer MAY backdate `iat` to tolerate a slow verifier clock; +the 14-day cap on `exp - iat` still applies as written. Freshness comes from +the forge fetching the proof live, so a stale proof at a dead node does not +verify. + +What this proves: the key holder controls what is served at that origin right +now. It does not prove the node is dialable over libp2p (a CDN can serve the +static file while the node is down). For proven dialability, use a libp2p +address. + +Notes for operators of the endpoint: + +- The proof is offline-signable. The identity key does not have to be online in + the HTTP tier. A static file server or a CDN can serve the token. +- The endpoint may answer on any port, so a node behind NAT can serve the + proof on a port forwarded via UPnP or router config. The public IP is what + is being proven and denylisted; the port does not matter, and the + libp2p-dialback mode accepts arbitrary ports too. +- The forge MUST pin each connection to a vetted resolved public IP of the + endpoint (it MAY try one address per family when the host resolves to both), + MUST refuse a non-public target, and MUST NOT follow redirects. It does not + require a CA-verified TLS certificate on this fetch: a node registers + precisely because it has no publicly trusted cert yet, and the proof's + authenticity comes from its signature plus the pinned IP, not from the + transport. + +### libp2p-dialback + +Support for this mode is OPTIONAL. It exists so nodes that already speak libp2p +can register with no HTTP endpoint to serve, but a forge MAY implement only +http-ownership and skip the libp2p stack entirely. + +For a libp2p multiaddr, the forge opens a libp2p connection to the peer and the +transport handshake authenticates the ``. This works for QUIC-v1; TCP or +WS or WSS with Yamux and TLS or Noise; and WebTransport, plus the +[Identify protocol](https://github.com/libp2p/specs/tree/master/identify). A +forge that supports this mode MUST resolve and pin the address IPs, MUST refuse +non-public targets, and MUST bound the dial with a timeout. + +## Errors + +The server SHOULD return [problem+json (RFC 9457)](https://www.rfc-editor.org/rfc/rfc9457) +and MUST use a status consistent with the table below. Each error class has a +stable `type` URI ending in the fragment shown, which anchors to that row. A +client that needs to tell classes apart SHOULD match on the whole `type` URI +(the fragment alone is enough in practice). + +| Status | `type` fragment | When | +| --- | --- | --- | +| `400` | `unexpected-query` | The request carries a query string. | +| `400` | `malformed-body` | The `Content-Type` is not `application/json`, the body is not the JSON object above, has unknown fields, or has trailing data. | +| `400` | `malformed-value` | `value` is not unpadded base64url of a 32-byte SHA-256 digest. | +| `400` | `malformed-signature` | The request does not conform to this profile: unparseable signature headers, a label other than `sig1`, covered components other than the exact list above, an unknown signature parameter, an `alg` other than `ed25519`, a bad `did:key`, a `nonce` that is not unpadded base64url of at least 128 bits, a missing `created` or `expires`, `expires - created` over 300 seconds, or a `Content-Digest` that is malformed or does not match the body. | +| `401` | `signature-invalid` | Signature verification failed, a required component is not covered, the clock window is violated, or `@authority` is not the registration domain. | +| `403` | `denylisted` | The client IP or a submitted address is denylisted. | +| `413` | `body-too-large` | The body exceeds 8 KiB. | +| `422` | `verification-failed` | No submitted address could be verified. | +| `500` | `misconfigured`, `storage-error` | Server-side failure; safe to retry later. | + +A fronting proxy or implementation-specific access control may add statuses +outside this table, such as `429` when rate limited or `403` when access is +denied. + +## Anti-abuse + +- **Rate limiting** is the operator's responsibility on the fronting reverse + proxy, CDN, or load balancer. The forge does not rate-limit requests itself. +- **Replay.** The server does not track nonces, so a captured request can be + resubmitted until its `expires` passes. This is accepted: the signature + binds the whole request, so a replay can only repeat it, re-verifying the + same addresses and re-writing the same TXT value. The `nonce` keeps every + signature unique, and a server MAY additionally reject reused nonces. +- **Denylist** applies to the client IP and to every resolved endpoint IP. The + forge MUST NOT trust a leftmost `X-Forwarded-For` for the client IP, which any + client can forge; it trusts only the direct connection address plus, when the + operator configures one, a proxy header (see `client-ip-header`). + +## Operator configuration + +Two settings in the `acme` Corefile block affect `/v2` (see the README for the +full syntax): + +- `allow-private-addresses=true` (an argument on the `registration-domain` + line) turns off every reachability safeguard: destination-IP vetting, the + address caps, the dialback IP pinning, and the verification timeouts. Off by + default. Use it only for local testing or a private deployment that trusts + the submitted addresses. Never enable it on a public instance. +- `client-ip-header ` names the header the fronting proxy sets with the + real client IP (for example `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 the proxy controls; `X-Forwarded-For` is refused at + startup, because a client can prepend to it and dodge the denylist. Without + this option, only the direct connection address is trusted. + +## Adding a key type + +`/v2` is Ed25519-only today (2026-Q3). The request profile is built to add key types +without a breaking change; the near-term driver is post-quantum signatures. See +[Adding a key type](key-types.md) for how a new type fits, the DNS-label size +limit, and the standards each candidate depends on. diff --git a/e2e_test.go b/e2e_test.go index 67a4e1d..c7b2013 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -117,7 +117,7 @@ func NewTestInfrastructure(t *testing.T) *TestInfrastructure { errors ipparser %s acme %s { - registration-domain %s listen-address=:%d external-tls=true + registration-domain %s listen-address=:%d external-tls=true allow-private-addresses=true database-type badger %s } }`, forge, forge, forgeRegistration, httpPort, tmpDir) @@ -314,6 +314,58 @@ func TestSetACMEChallenge(t *testing.T) { } } +// TestSetACMEChallengeV2 exercises the full v2 stack: an RFC 9421-signed +// registration (no libp2p PeerID-auth handshake), the SSRF-hardened dialback, +// and the unchanged DNS-01 TXT readback. +func TestSetACMEChallengeV2(t *testing.T) { + t.Parallel() + testInfra := NewTestInfrastructure(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sk, _, err := crypto.GenerateEd25519Key(rand.Reader) + if err != nil { + t.Fatal(err) + } + + h, err := libp2p.New(libp2p.Identity(sk)) + if err != nil { + t.Fatal(err) + } + + testDigest := sha256.Sum256([]byte("test-v2")) + testChallenge := base64.RawURLEncoding.EncodeToString(testDigest[:]) + + err = client.SendChallengeV2(ctx, fmt.Sprintf("http://127.0.0.1:%d", testInfra.HTTPPort), sk, testChallenge, h.Addrs(), authToken, "", func(req *http.Request) error { + req.Host = forgeRegistration + return nil + }) + if err != nil { + t.Fatal(err) + } + + peerIDb36, err := peer.ToCid(h.ID()).StringOfBase(multibase.Base36) + if err != nil { + t.Fatal(err) + } + + m := new(dns.Msg) + m.Question = make([]dns.Question, 1) + m.Question[0] = dns.Question{Qclass: dns.ClassINET, Name: fmt.Sprintf("_acme-challenge.%s.%s.", peerIDb36, forge), Qtype: dns.TypeTXT} + + r, err := dns.Exchange(m, testInfra.DNSServerUDPAddress) + if err != nil { + t.Fatalf("Could not send message: %s", err) + } + if r.Rcode != dns.RcodeSuccess || len(r.Answer) == 0 { + t.Fatalf("Expected successful reply with TXT value, got empty %s", dns.RcodeToString[r.Rcode]) + } + expectedAnswer := fmt.Sprintf(`%s 10 IN TXT "%s"`, m.Question[0].Name, testChallenge) + if r.Answer[0].String() != expectedAnswer { + t.Fatalf("Expected %s reply, got %s", expectedAnswer, r.Answer[0].String()) + } +} + // Confirm we ALWAYS return empty TXT instead of NODATA to avoid // issues described in https://github.com/ipshipyard/p2p-forge/issues/52 func TestACMEChallengeNoDNS01Value(t *testing.T) { @@ -1052,7 +1104,7 @@ func NewTestInfrastructureWithDenylist(t *testing.T, cfg DenylistTestConfig) *Te %s ipparser %s acme %s { - registration-domain %s listen-address=:%d external-tls=true + registration-domain %s listen-address=:%d external-tls=true allow-private-addresses=true database-type badger %s } }`, denylistBlock.String(), forge, forge, forgeRegistration, httpPort, tmpDir) diff --git a/go.mod b/go.mod index 5f4c493..2c5c1f0 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/caddyserver/certmagic v0.25.3 github.com/coredns/caddy v1.1.4 github.com/coredns/coredns v1.14.3 + github.com/dunglas/httpsfv v1.1.0 github.com/felixge/httpsnoop v1.0.4 github.com/fsnotify/fsnotify v1.10.1 github.com/gaissmai/bart v0.28.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/ipfs/go-datastore v0.9.1 github.com/ipfs/go-ds-badger4 v0.1.8 github.com/ipfs/go-ds-dynamodb v0.3.0 @@ -24,9 +26,11 @@ require ( github.com/multiformats/go-multiaddr v0.16.1 github.com/multiformats/go-multiaddr-dns v0.5.0 github.com/multiformats/go-multibase v0.3.0 + github.com/multiformats/go-multicodec v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/slok/go-http-metrics v0.13.0 github.com/stretchr/testify v1.11.1 + github.com/yaronf/httpsign v0.5.2 go.uber.org/zap v1.28.0 ) @@ -61,12 +65,11 @@ require ( github.com/dgraph-io/badger/v4 v4.5.1 // indirect github.com/dgraph-io/ristretto/v2 v2.1.0 // indirect github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect - github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect github.com/flynn/noise v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/flatbuffers v24.12.23+incompatible // indirect github.com/google/uuid v1.6.0 // indirect @@ -81,6 +84,17 @@ require ( github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/koron/go-ssdp v0.0.6 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.2 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.12 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/letsencrypt/challtestsrv v1.4.2 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect @@ -101,7 +115,6 @@ require ( github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect - github.com/multiformats/go-multicodec v0.9.1 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-multistream v0.6.1 // indirect github.com/multiformats/go-varint v0.0.7 // indirect @@ -137,7 +150,9 @@ require ( github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/webtransport-go v0.10.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/valyala/fastjson v1.6.4 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index c181b6b..0a71b3c 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5/go.mod h1:OjOXDNlClLblvXd filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b h1:REI1FbdW71yO56Are4XAxD+OS/e+BQsB3gE4mZRQEXY= filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b/go.mod h1:9nnw1SlYHYuPSo/3wjQzNjSbeHlq2NsKo5iEtfJPWP0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU= github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -105,8 +107,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -180,6 +184,28 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= +github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc= +github.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y= +github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= +github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= github.com/letsencrypt/pebble/v2 v2.10.1 h1:oKHx3lgN4e5Nno2LKTMrVx+b+NkDptkO9aDireiBDGE= @@ -323,6 +349,10 @@ github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+ github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8= github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -333,13 +363,18 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yaronf/httpsign v0.5.2 h1:9+39l7+BajAx5RUmlG6MyJ3AfIl893VfH3zcVvQOcS4= +github.com/yaronf/httpsign v0.5.2/go.mod h1:euOXi3++HLtx5YlsJEWcIzF3ztK4TL2M2F0Wg3KL+V0= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= diff --git a/internal/httpsig/didkey.go b/internal/httpsig/didkey.go new file mode 100644 index 0000000..6fd52f3 --- /dev/null +++ b/internal/httpsig/didkey.go @@ -0,0 +1,72 @@ +// Package httpsig implements the fixed-profile HTTP Message Signatures +// (RFC 9421) and Digest Fields (RFC 9530) used by the p2p-forge /v2 +// registration API. It is shared by the client (signer) and the acme server +// (verifier) so both sides build the same signature base by construction. +// +// The profile is deliberately closed: Ed25519 keys only, a fixed set of +// covered components, and did:key key identifiers. This keeps the protocol +// surface libp2p-agnostic (a did:key is a generic W3C identifier) and lets a +// non-Go client sign a request with any RFC 9421 tooling. +package httpsig + +import ( + "bytes" + "encoding/binary" + "fmt" + "strings" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" +) + +// didKeyPrefix is the fixed scheme+method prefix of a did:key identifier. +const didKeyPrefix = "did:key:" + +// ed25519PubMulticodec is the ed25519-pub multicodec (0xed) as an unsigned +// varint, the byte prefix a did:key places before the raw public key. +var ed25519PubMulticodec = binary.AppendUvarint(nil, uint64(multicodec.Ed25519Pub)) + +// EncodeDIDKey returns the did:key form of an Ed25519 public key, e.g. +// "did:key:z6Mk...". Only Ed25519 is supported; /v2 is Ed25519-first and other +// libp2p key types continue to use /v1. +func EncodeDIDKey(pub crypto.PubKey) (string, error) { + if _, ok := pub.(*crypto.Ed25519PublicKey); !ok { + return "", fmt.Errorf("did:key: only Ed25519 keys are supported, got %s", pub.Type()) + } + raw, err := pub.Raw() + if err != nil { + return "", fmt.Errorf("did:key: reading raw public key: %w", err) + } + prefixed := append(append([]byte{}, ed25519PubMulticodec...), raw...) + mb, err := multibase.Encode(multibase.Base58BTC, prefixed) + if err != nil { + return "", fmt.Errorf("did:key: multibase encode: %w", err) + } + return didKeyPrefix + mb, nil +} + +// DecodeDIDKey parses a did:key into an Ed25519 public key. It rejects any +// other multibase or multicodec so there is exactly one accepted encoding. +func DecodeDIDKey(did string) (crypto.PubKey, error) { + suffix, ok := strings.CutPrefix(did, didKeyPrefix) + if !ok { + return nil, fmt.Errorf("did:key: missing %q prefix", didKeyPrefix) + } + enc, data, err := multibase.Decode(suffix) + if err != nil { + return nil, fmt.Errorf("did:key: multibase decode: %w", err) + } + if enc != multibase.Base58BTC { + return nil, fmt.Errorf("did:key: expected base58btc (z...), got multibase %q", enc) + } + raw, ok := bytes.CutPrefix(data, ed25519PubMulticodec) + if !ok { + return nil, fmt.Errorf("did:key: not an Ed25519 key (unexpected multicodec prefix)") + } + pub, err := crypto.UnmarshalEd25519PublicKey(raw) + if err != nil { + return nil, fmt.Errorf("did:key: invalid Ed25519 public key: %w", err) + } + return pub, nil +} diff --git a/internal/httpsig/didkey_test.go b/internal/httpsig/didkey_test.go new file mode 100644 index 0000000..b025f6c --- /dev/null +++ b/internal/httpsig/didkey_test.go @@ -0,0 +1,56 @@ +package httpsig + +import ( + "bytes" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/stretchr/testify/require" +) + +func TestDIDKeyRoundTrip(t *testing.T) { + priv, pub, err := crypto.GenerateEd25519Key(bytes.NewReader(bytes.Repeat([]byte{0x07}, 64))) + require.NoError(t, err) + _ = priv + + did, err := EncodeDIDKey(pub) + require.NoError(t, err) + // Ed25519 did:keys always start with z6Mk (multicodec 0xed01 prefix). + require.True(t, len(did) > len("did:key:z6Mk")) + require.Contains(t, did, "did:key:z6Mk") + + got, err := DecodeDIDKey(did) + require.NoError(t, err) + require.True(t, got.Equals(pub)) + + // The derived peer.ID must match the one libp2p derives, so v2 and the DNS + // layer agree on identity. + wantPID, err := peer.IDFromPublicKey(pub) + require.NoError(t, err) + gotPID, err := peer.IDFromPublicKey(got) + require.NoError(t, err) + require.Equal(t, wantPID, gotPID) +} + +func TestDecodeDIDKeyRejects(t *testing.T) { + cases := map[string]string{ + "missing prefix": "z6MkfooBar", + "not base58btc": "did:key:f7b22", + "wrong multicodec": "did:key:z2Dd", // secp/p256-ish prefix, not ed25519 + "garbage": "did:key:z!!!!", + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + _, err := DecodeDIDKey(in) + require.Error(t, err) + }) + } +} + +func TestEncodeDIDKeyRejectsNonEd25519(t *testing.T) { + _, pub, err := crypto.GenerateSecp256k1Key(bytes.NewReader(bytes.Repeat([]byte{0x09}, 64))) + require.NoError(t, err) + _, err = EncodeDIDKey(pub) + require.ErrorContains(t, err, "Ed25519") +} diff --git a/internal/httpsig/profile.go b/internal/httpsig/profile.go new file mode 100644 index 0000000..2f97a06 --- /dev/null +++ b/internal/httpsig/profile.go @@ -0,0 +1,43 @@ +// Package httpsig pins the p2p-forge /v2 request-signing profile and the +// did:key key identifier. The RFC 9421 signing and verification themselves are +// delegated to github.com/yaronf/httpsign; this package only fixes the profile +// (covered components, parameters, clock bounds) so client and server agree. +package httpsig + +import ( + "net" + "strings" + "time" +) + +const ( + // SigLabel is the fixed Signature-Input / Signature dictionary label. + SigLabel = "sig1" + // RegistrationTag domain-separates a registration signature from any other + // RFC 9421 use. + RegistrationTag = "p2p-forge-reg" + + // MaxSignatureLifetime bounds expires-created. No extra grace for client + // clock skew is needed: a skewed clock shifts created and expires by the + // same amount, so the expires check covers it. + MaxSignatureLifetime = 5 * time.Minute + // MaxForwardDrift is how far in the future `created` may be. + MaxForwardDrift = 30 * time.Second + // MinNonceBytes is the minimum nonce entropy: 128 bits, carried as + // unpadded base64url in the nonce parameter. + MinNonceBytes = 16 +) + +// RegistrationComponents is the fixed, ordered set of covered components for a +// /v2 registration request. +var RegistrationComponents = []string{"@method", "@authority", "@path", "content-type", "content-digest"} + +// CanonicalAuthority lowercases an authority and strips a default http/https +// port, so the server can compare @authority against its configured domain. +func CanonicalAuthority(a string) string { + a = strings.ToLower(strings.TrimSpace(a)) + if host, port, err := net.SplitHostPort(a); err == nil && (port == "80" || port == "443") { + return host + } + return a +} diff --git a/internal/ownership/ownership.go b/internal/ownership/ownership.go new file mode 100644 index 0000000..cfb1723 --- /dev/null +++ b/internal/ownership/ownership.go @@ -0,0 +1,91 @@ +// Package ownership implements the p2p-forge /v2 http-ownership proof: a small +// EdDSA JWT a node serves at a well-known path to prove its key controls an HTTP +// origin. Using a JWT keeps the proof a standard, offline-signable, cacheable +// artifact rather than a bespoke signature format. +package ownership + +import ( + "crypto/ed25519" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Proof validity bounds. The signer picks a window up to MaxWindow; the verifier +// caps it. Freshness comes from the forge fetching the proof live, so the window +// only bounds how long a proof stays valid after the key holder loses the origin. +const ( + DefaultWindow = 24 * time.Hour + MaxWindow = 14 * 24 * time.Hour + clockSkew = 5 * time.Minute + backdate = time.Minute // small backdate of iat to tolerate verifier skew +) + +// proofType is the explicit JWT type (RFC 8725 section 3.11). The same Ed25519 +// identity key signs other artifacts, and some of those are JWTs too; typing +// the header stops any of them from passing as an ownership proof. +const proofType = "p2p-forge-ownership+jwt" + +// claims is the proof payload: the standard registered claims plus the origin +// the key controls. +type claims struct { + Origin string `json:"origin"` + jwt.RegisteredClaims +} + +// Sign returns a compact EdDSA JWT proving priv controls origin, valid for ttl +// (clamped to MaxWindow). iat is backdated slightly to tolerate verifier clock +// skew, and exp counts from the backdated iat, so exp-iat never exceeds ttl. +func Sign(priv ed25519.PrivateKey, origin string, now time.Time, ttl time.Duration) (string, error) { + if ttl <= 0 || ttl > MaxWindow { + ttl = DefaultWindow + } + iat := now.Add(-backdate) + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + Origin: origin, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(iat), + ExpiresAt: jwt.NewNumericDate(iat.Add(ttl)), + }, + }) + token.Header["typ"] = proofType + s, err := token.SignedString(priv) + if err != nil { + return "", fmt.Errorf("signing ownership proof: %w", err) + } + return s, nil +} + +// Verify checks the proof under pub (the registration key, never a key the token +// names), confirms the origin matches, and enforces the validity window. now is +// injectable for tests. +func Verify(token string, pub ed25519.PublicKey, expectedOrigin string, now time.Time) error { + var c claims + parser := jwt.NewParser( + jwt.WithValidMethods([]string{"EdDSA"}), + jwt.WithExpirationRequired(), + jwt.WithIssuedAt(), + jwt.WithLeeway(clockSkew), + jwt.WithTimeFunc(func() time.Time { return now }), + ) + tok, err := parser.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { + return pub, nil + }) + if err != nil { + return fmt.Errorf("ownership proof invalid: %w", err) + } + if typ, _ := tok.Header["typ"].(string); typ != proofType { + return fmt.Errorf("ownership proof typ %q is not %q", typ, proofType) + } + if c.IssuedAt == nil || c.ExpiresAt == nil { + return fmt.Errorf("ownership proof missing iat/exp") + } + if c.ExpiresAt.Sub(c.IssuedAt.Time) > MaxWindow { + return fmt.Errorf("ownership proof window exceeds %s", MaxWindow) + } + if c.Origin != expectedOrigin { + return fmt.Errorf("ownership proof origin %q does not match %q", c.Origin, expectedOrigin) + } + return nil +} diff --git a/internal/ownership/ownership_test.go b/internal/ownership/ownership_test.go new file mode 100644 index 0000000..f44b052 --- /dev/null +++ b/internal/ownership/ownership_test.go @@ -0,0 +1,99 @@ +package ownership + +import ( + "crypto/ed25519" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func mustKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) // crypto/rand + require.NoError(t, err) + return priv, pub +} + +func TestOwnershipRoundTrip(t *testing.T) { + priv, pub := mustKeys(t) + now := time.Unix(1_700_000_000, 0) + origin := "https://gw.example:443" + + tok, err := Sign(priv, origin, now, DefaultWindow) + require.NoError(t, err) + require.NoError(t, Verify(tok, pub, origin, now)) +} + +func TestOwnershipRejects(t *testing.T) { + priv, pub := mustKeys(t) + _, otherPub := mustKeys(t) + now := time.Unix(1_700_000_000, 0) + origin := "https://gw.example:443" + + tok, err := Sign(priv, origin, now, time.Hour) + require.NoError(t, err) + + t.Run("origin mismatch", func(t *testing.T) { + require.ErrorContains(t, Verify(tok, pub, "https://evil.example:443", now), "origin") + }) + t.Run("scheme or port mismatch", func(t *testing.T) { + require.Error(t, Verify(tok, pub, "http://gw.example:80", now)) + }) + t.Run("wrong verification key", func(t *testing.T) { + require.Error(t, Verify(tok, otherPub, origin, now)) + }) + t.Run("expired", func(t *testing.T) { + require.ErrorContains(t, Verify(tok, pub, origin, now.Add(2*time.Hour)), "invalid") + }) + t.Run("garbage token", func(t *testing.T) { + require.Error(t, Verify("not.a.jwt", pub, origin, now)) + }) +} + +func TestOwnershipWindowCap(t *testing.T) { + priv, pub := mustKeys(t) + now := time.Unix(1_700_000_000, 0) + origin := "https://gw.example:443" + + t.Run("full window verifies", func(t *testing.T) { + tok, err := Sign(priv, origin, now, MaxWindow) + require.NoError(t, err) + require.NoError(t, Verify(tok, pub, origin, now)) + }) + + t.Run("window over the cap rejected", func(t *testing.T) { + // Sign clamps, so forge the too-wide token directly. + wide := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + Origin: origin, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(MaxWindow + time.Hour)), + }, + }) + wide.Header["typ"] = proofType + s, err := wide.SignedString(priv) + require.NoError(t, err) + require.ErrorContains(t, Verify(s, pub, origin, now), "window exceeds") + }) +} + +func TestOwnershipRequiresExplicitType(t *testing.T) { + priv, pub := mustKeys(t) + now := time.Unix(1_700_000_000, 0) + origin := "https://gw.example:443" + + // A JWT with the right claims but the default typ must not pass as an + // ownership proof. + untyped := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + Origin: origin, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + }, + }) + s, err := untyped.SignedString(priv) + require.NoError(t, err) + require.ErrorContains(t, Verify(s, pub, origin, now), "typ") +}