From 9a3b2f5db5d44b5f4ec3b4ef3acb82d4a7c8703b Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 12:42:25 +0200 Subject: [PATCH 01/27] feat: add RFC 9421 /v2 registration API Add an opt-in /v2 registration path that authenticates with HTTP Message Signatures (RFC 9421) plus Digest Fields (RFC 9530) instead of the libp2p PeerID-auth handshake, so a client needs only an Ed25519 signer rather than a libp2p HTTP stack. v1 is unchanged and stays the default. - internal/httpsig: fixed-profile RFC 9421 signer/verifier, RFC 9530 Content-Digest, and did:key (Ed25519) encode/decode, shared by client and server; golden signature-base and did:key round-trip tests - acme: POST /v2/_acme-challenge, GET /v2/health, and a GET /v2 profile mounted beside v1 on the same mux and datastore; identity is derived from the signature keyid via peer.IDFromPublicKey, the real-node check reuses the existing dialback, and the DNS-01 write matches v1 exactly - client: SendChallengeV2 (single signed POST, no cookie jar) and WithRegistrationAPIVersion (v1 default, v2, or auto with v1 fallback) The v2 wire surface stays libp2p-agnostic: requests and the success response carry a did:key, never a raw peerid. --- acme/setup.go | 1 + acme/writer.go | 8 + acme/writer_v2.go | 213 ++++++++++++++ acme/writer_v2_test.go | 181 ++++++++++++ client/acme.go | 62 +++- client/challenge.go | 15 +- client/challenge_v2.go | 126 ++++++++ e2e_test.go | 52 ++++ go.mod | 4 +- internal/httpsig/didkey.go | 72 +++++ internal/httpsig/didkey_test.go | 56 ++++ internal/httpsig/digest.go | 52 ++++ internal/httpsig/httpsig.go | 476 +++++++++++++++++++++++++++++++ internal/httpsig/httpsig_test.go | 278 ++++++++++++++++++ 14 files changed, 1570 insertions(+), 26 deletions(-) create mode 100644 acme/writer_v2.go create mode 100644 acme/writer_v2_test.go create mode 100644 client/challenge_v2.go create mode 100644 internal/httpsig/didkey.go create mode 100644 internal/httpsig/didkey_test.go create mode 100644 internal/httpsig/digest.go create mode 100644 internal/httpsig/httpsig.go create mode 100644 internal/httpsig/httpsig_test.go diff --git a/acme/setup.go b/acme/setup.go index 8781e93..8f4613f 100644 --- a/acme/setup.go +++ b/acme/setup.go @@ -164,6 +164,7 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { writer := &acmeWriter{ Addr: httpListenAddr, Domain: forgeRegistrationDomain, + ForgeDomain: forgeDomain, Datastore: ds, ExternalTLS: externalTLS, } diff --git a/acme/writer.go b/acme/writer.go index 8357ec9..d098a15 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -47,6 +47,7 @@ const healthcheckApiPath = "/v1/health" type acmeWriter struct { Addr string Domain string + ForgeDomain string ExternalTLS bool Datastore datastore.TTLDatastore @@ -210,6 +211,13 @@ 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.Handle("GET "+profileV2ApiPath, std.Handler(profileV2ApiPath, httpMetricsMiddleware, http.HandlerFunc(c.handleV2Profile))) + mux.HandleFunc("GET "+healthV2ApiPath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + c.handler = withRequestLogger(mux) go func() { diff --git a/acme/writer_v2.go b/acme/writer_v2.go new file mode 100644 index 0000000..ce024e7 --- /dev/null +++ b/acme/writer_v2.go @@ -0,0 +1,213 @@ +package acme + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/ipfs/go-datastore" + "github.com/ipshipyard/p2p-forge/client" + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "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" + profileV2ApiPath = "/v2" +) + +// maxV2BodySize bounds the registration request body. +const maxV2BodySize = 8 << 10 // 8 KiB + +// 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: optional shared-secret access token. + if c.forgeAuthKey != "" && r.Header.Get(client.ForgeAuthHeader) != c.forgeAuthKey { + writeProblem(w, http.StatusForbidden, "forbidden", fmt.Sprintf("missing or invalid %s header", client.ForgeAuthHeader)) + 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. This + // yields the authenticated public key; identity is derived from it, never + // from the body. + verified, err := httpsig.VerifyRequest(r, body, httpsig.VerifyConfig{ + Authority: c.Domain, + Now: time.Now(), + }) + if err != nil { + writeProblem(w, http.StatusUnauthorized, "signature-invalid", err.Error()) + return + } + peerID, err := peer.IDFromPublicKey(verified.PubKey) + if err != nil { + writeProblem(w, http.StatusUnauthorized, "signature-invalid", fmt.Sprintf("deriving peer ID: %s", err)) + return + } + + 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 := checkDenylist(clientIPs(r), typedBody.Addresses); blocked { + writeProblem(w, http.StatusForbidden, "denylisted", reason) + return + } + + // "Real node" check. For now this is the (unchanged) libp2p dialback; the + // hardened dialback and the http-ownership proof land in later commits. + if err := testAddresses(r.Context(), peerID, typedBody.Addresses, r.Header.Get("User-Agent")); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "verification-failed", fmt.Sprintf("no address verified: %s", err)) + return + } + + if err := c.Datastore.PutWithTTL(r.Context(), datastore.NewKey(peerID.String()), []byte(typedBody.Value), time.Hour); 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: v2Verification{ + Mode: "libp2p-dialback", + }, + TTL: int(time.Hour / time.Second), + }) +} + +// handleV2Profile serves a static descriptor so a generic signer can discover +// the required covered components and limits without reading source. +func (c *acmeWriter) handleV2Profile(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, v2Profile{ + Endpoint: registrationV2ApiPath, + KeyTypes: []string{"did:key (Ed25519)"}, + CoveredComponents: []string{"@method", "@authority", "@path", "content-type", "content-digest"}, + SignatureParams: []string{"created", "expires", "nonce", "keyid", "tag"}, + SignatureTag: httpsig.RegistrationTag, + MaxBodyBytes: maxV2BodySize, + MaxSignatureAgeS: int(httpsig.MaxSignatureLifetime / time.Second), + ContentDigest: "sha-256 (RFC 9530), required, covered by the signature", + }) +} + +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 v2Verification `json:"verification"` + TTL int `json:"ttl"` +} + +type v2Verification struct { + Mode string `json:"mode"` + Addr string `json:"addr,omitempty"` +} + +type v2Profile struct { + Endpoint string `json:"endpoint"` + KeyTypes []string `json:"keyTypes"` + CoveredComponents []string `json:"coveredComponents"` + SignatureParams []string `json:"signatureParams"` + SignatureTag string `json:"signatureTag"` + MaxBodyBytes int `json:"maxBodyBytes"` + MaxSignatureAgeS int `json:"maxSignatureAgeSeconds"` + ContentDigest string `json:"contentDigest"` +} + +// 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) +} + +// writeProblem emits an RFC 9457 problem+json response. +func writeProblem(w http.ResponseWriter, status int, problemType, detail string) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "https://specs.ipfs.tech/p2p-forge/v2/errors#" + problemType, + "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..761de5e --- /dev/null +++ b/acme/writer_v2_test.go @@ -0,0 +1,181 @@ +package acme + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ipfs/go-datastore" + "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" +) + +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 { + 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 + params, err := httpsig.NewSignParams(time.Now(), httpsig.MaxSignatureLifetime) + require.NoError(t, err) + require.NoError(t, httpsig.SignRequest(req, priv, body, params)) + 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 { + return &acmeWriter{ + Domain: v2TestDomain, + ForgeDomain: "libp2p.direct", + Datastore: ttlDatastore{datastore.NewMapDatastore()}, + } +} + +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.Mode) +} + +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 (covered by the signature) + // no longer matches. + 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.StatusUnauthorized, rec.Code) + }) + + 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) + }) +} + +func TestV2ProfileHandler(t *testing.T) { + rec := httptest.NewRecorder() + newTestWriter().handleV2Profile(rec, httptest.NewRequest(http.MethodGet, "/v2", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + var p v2Profile + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p)) + require.Equal(t, httpsig.RegistrationTag, p.SignatureTag) + require.Equal(t, registrationV2ApiPath, p.Endpoint) +} diff --git a/client/acme.go b/client/acme.go index a6a59b6..963430c 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,21 @@ 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). +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 +397,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 +659,7 @@ type dns01P2PForgeSolver struct { httpClient *http.Client userAgent string allowPrivateForgeAddresses bool + apiVersion RegistrationAPIVersion log *zap.SugaredLogger resolver *net.Resolver } @@ -722,20 +740,42 @@ 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..., - ) + 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...) + } + + var err error + switch d.apiVersion { + case RegistrationV2: + err = sendV2() + case RegistrationAuto: + // v2 needs an Ed25519 key; fall back to v1 when the key is unsupported + // or the forge has no v2 endpoint. + if isEd25519(privKey) { + if err = sendV2(); err == nil { + return nil + } + if !errors.Is(err, ErrV2Unsupported) { + return fmt.Errorf("p2p-forge broker registration error: %w", err) + } + d.log.Infow("v2 registration unavailable, falling back to v1", "err", err) + } else { + d.log.Debugw("identity key is not Ed25519, using v1 registration") + } + err = sendV1() + default: + err = sendV1() + } if err != nil { return fmt.Errorf("p2p-forge broker registration error: %w", err) } - return nil } diff --git a/client/challenge.go b/client/challenge.go index 7010bdc..64478b7 100644 --- a/client/challenge.go +++ b/client/challenge.go @@ -3,7 +3,6 @@ package client import ( "bytes" "context" - "encoding/json" "fmt" "io" "net/http" @@ -123,18 +122,8 @@ func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, // // 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..345150f --- /dev/null +++ b/client/challenge_v2.go @@ -0,0 +1,126 @@ +package client + +import ( + "bytes" + "context" + "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" +) + +// 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") + if userAgent == "" { + userAgent = defaultUserAgent + } + req.Header.Set("User-Agent", userAgent) + if forgeAuth != "" { + req.Header.Set(ForgeAuthHeader, forgeAuth) + } + + // modifyForgeRequest runs before signing: it may set req.Host, which is a + // covered component (@authority). + if modifyForgeRequest != nil { + if err := modifyForgeRequest(req); err != nil { + return err + } + } + + params, err := httpsig.NewSignParams(time.Now(), httpsig.MaxSignatureLifetime) + if err != nil { + return err + } + if err := httpsig.SignRequest(req, privKey, body, params); err != nil { + return fmt.Errorf("signing v2 registration request: %w", 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() + + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed { + return fmt.Errorf("%w: %s", ErrV2Unsupported, resp.Status) + } + if resp.StatusCode != http.StatusOK { + respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + if readErr != nil { + return fmt.Errorf("%s from %s (reading error body failed: %w)", resp.Status, registrationURL, readErr) + } + return fmt.Errorf("%s error from %s: %q", resp.Status, registrationURL, respBody) + } + 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. +func isEd25519(k crypto.PrivKey) bool { + _, ok := k.(*crypto.Ed25519PrivateKey) + return ok +} diff --git a/e2e_test.go b/e2e_test.go index 67a4e1d..84f1612 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -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 hardened-later 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) { diff --git a/go.mod b/go.mod index 5f4c493..2af7476 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ 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 @@ -24,6 +25,7 @@ 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 @@ -61,7 +63,6 @@ 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 @@ -101,7 +102,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 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/digest.go b/internal/httpsig/digest.go new file mode 100644 index 0000000..e23dc1d --- /dev/null +++ b/internal/httpsig/digest.go @@ -0,0 +1,52 @@ +package httpsig + +import ( + "bytes" + "crypto/sha256" + "fmt" + + "github.com/dunglas/httpsfv" +) + +// contentDigestHeader is the RFC 9530 header carrying the body hash. +const contentDigestHeader = "Content-Digest" + +// ContentDigest returns the RFC 9530 Content-Digest field value for a body, +// using SHA-256, e.g. `sha-256=:Mv9b...=:`. +func ContentDigest(body []byte) (string, error) { + sum := sha256.Sum256(body) + d := httpsfv.NewDictionary() + d.Add("sha-256", httpsfv.NewItem(sum[:])) + s, err := httpsfv.Marshal(d) + if err != nil { + return "", fmt.Errorf("content-digest: marshal: %w", err) + } + return s, nil +} + +// verifyContentDigest checks that the received Content-Digest field value +// carries a sha-256 digest matching body. Additional algorithms are ignored +// per RFC 9530; sha-256 must be present and correct. +func verifyContentDigest(fieldValue string, body []byte) error { + dict, err := httpsfv.UnmarshalDictionary([]string{fieldValue}) + if err != nil { + return fmt.Errorf("content-digest: parse: %w", err) + } + member, ok := dict.Get("sha-256") + if !ok { + return fmt.Errorf("content-digest: missing sha-256") + } + item, ok := member.(httpsfv.Item) + if !ok { + return fmt.Errorf("content-digest: sha-256 is not an item") + } + got, ok := item.Value.([]byte) + if !ok { + return fmt.Errorf("content-digest: sha-256 is not a byte sequence") + } + want := sha256.Sum256(body) + if !bytes.Equal(got, want[:]) { + return fmt.Errorf("content-digest: sha-256 does not match body") + } + return nil +} diff --git a/internal/httpsig/httpsig.go b/internal/httpsig/httpsig.go new file mode 100644 index 0000000..74d73e6 --- /dev/null +++ b/internal/httpsig/httpsig.go @@ -0,0 +1,476 @@ +package httpsig + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/dunglas/httpsfv" + "github.com/libp2p/go-libp2p/core/crypto" +) + +// Signature-Input / Signature tags identify which fixed profile a signature +// belongs to. The tag is covered by the signature, so it also domain-separates +// a registration signature from an ownership-proof signature and from any +// unrelated RFC 9421 service. +const ( + RegistrationTag = "p2p-forge-reg" + OwnershipTag = "p2p-forge-ownership" +) + +// Freshness bounds for a registration signature. Exported so docs and callers +// reference the constant rather than a drifting literal. +const ( + // MaxSignatureLifetime bounds expires-created. + MaxSignatureLifetime = 5 * time.Minute + // MaxClockSkew is how far in the past `expires` may already be. + MaxClockSkew = 2 * time.Minute + // MaxForwardDrift is how far in the future `created` may be. + MaxForwardDrift = 30 * time.Second +) + +// sigLabel is the fixed Signature-Input / Signature dictionary label. The +// profile carries a single signature per request. +const sigLabel = "sig1" + +// minNonceLen is the minimum accepted nonce length. Callers SHOULD use >=128 +// bits of entropy; GenerateNonce produces 16 random bytes. +const minNonceLen = 16 + +// registrationComponents is the fixed, ordered set of covered components for a +// /v2 registration request. Verification rejects any request covering a +// different set. +var registrationComponents = []string{"@method", "@authority", "@path", "content-type", "content-digest"} + +// SignParams are the per-request RFC 9421 signature parameters chosen by the +// signer. Created/Expires are unix seconds. +type SignParams struct { + Created int64 + Expires int64 + Nonce string +} + +// NewSignParams builds SignParams valid for lifetime starting at now, with a +// fresh random nonce. lifetime is clamped to MaxSignatureLifetime. +func NewSignParams(now time.Time, lifetime time.Duration) (SignParams, error) { + if lifetime <= 0 || lifetime > MaxSignatureLifetime { + lifetime = MaxSignatureLifetime + } + nonce, err := GenerateNonce() + if err != nil { + return SignParams{}, err + } + return SignParams{ + Created: now.Unix(), + Expires: now.Add(lifetime).Unix(), + Nonce: nonce, + }, nil +} + +// GenerateNonce returns a fresh base64url nonce with 128 bits of entropy. +func GenerateNonce() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// SignRequest signs r in place for the registration profile: it sets +// Content-Type (if unset), Content-Digest, Signature-Input and Signature. body +// must be the exact bytes of the request body. +func SignRequest(r *http.Request, priv crypto.PrivKey, body []byte, p SignParams) error { + keyID, err := EncodeDIDKey(priv.GetPublic()) + if err != nil { + return err + } + if len(p.Nonce) < minNonceLen { + return fmt.Errorf("nonce too short (%d < %d)", len(p.Nonce), minNonceLen) + } + + cd, err := ContentDigest(body) + if err != nil { + return err + } + r.Header.Set(contentDigestHeader, cd) + if r.Header.Get("Content-Type") == "" { + r.Header.Set("Content-Type", "application/json") + } + + il := buildInnerList(registrationComponents, sigMeta{ + created: p.Created, + expires: p.Expires, + nonce: p.Nonce, + keyID: keyID, + tag: RegistrationTag, + }) + sigParams, err := httpsfv.Marshal(il) + if err != nil { + return fmt.Errorf("marshal signature params: %w", err) + } + comps, err := deriveComponents(r, registrationComponents) + if err != nil { + return err + } + sig, err := priv.Sign([]byte(signatureBase(comps, sigParams))) + if err != nil { + return fmt.Errorf("signing: %w", err) + } + + inputDict := httpsfv.NewDictionary() + inputDict.Add(sigLabel, il) + inputStr, err := httpsfv.Marshal(inputDict) + if err != nil { + return fmt.Errorf("marshal Signature-Input: %w", err) + } + r.Header.Set("Signature-Input", inputStr) + + sigDict := httpsfv.NewDictionary() + sigDict.Add(sigLabel, httpsfv.NewItem(sig)) + sigStr, err := httpsfv.Marshal(sigDict) + if err != nil { + return fmt.Errorf("marshal Signature: %w", err) + } + r.Header.Set("Signature", sigStr) + return nil +} + +// VerifiedRequest is the authenticated result of a valid registration request. +type VerifiedRequest struct { + PubKey crypto.PubKey + KeyID string + Nonce string + Created int64 + Expires int64 +} + +// VerifyConfig carries the server-side verification inputs. +type VerifyConfig struct { + // Authority is the expected @authority (the registration domain), compared + // after canonicalization. + Authority string + // Now is the reference time (injectable for tests). + Now time.Time +} + +// VerifyRequest verifies r against the fixed registration profile and returns +// the authenticated key. It checks Content-Digest against body, the covered +// component set, the freshness window, the authority, and the signature. The +// caller is responsible for nonce single-use (replay) and rate limiting. +func VerifyRequest(r *http.Request, body []byte, cfg VerifyConfig) (*VerifiedRequest, error) { + wantAuthority := canonicalAuthority(cfg.Authority) + if wantAuthority == "" { + return nil, errors.New("verifier misconfigured: empty authority") + } + + cds := r.Header.Values(contentDigestHeader) + if len(cds) != 1 { + return nil, fmt.Errorf("expected exactly one Content-Digest header, got %d", len(cds)) + } + if err := verifyContentDigest(cds[0], body); err != nil { + return nil, err + } + + inputs := r.Header.Values("Signature-Input") + if len(inputs) != 1 { + return nil, fmt.Errorf("expected exactly one Signature-Input header, got %d", len(inputs)) + } + sigs := r.Header.Values("Signature") + if len(sigs) != 1 { + return nil, fmt.Errorf("expected exactly one Signature header, got %d", len(sigs)) + } + inputDict, err := httpsfv.UnmarshalDictionary([]string{inputs[0]}) + if err != nil { + return nil, fmt.Errorf("parse Signature-Input: %w", err) + } + sigDict, err := httpsfv.UnmarshalDictionary([]string{sigs[0]}) + if err != nil { + return nil, fmt.Errorf("parse Signature: %w", err) + } + + label, il, err := selectByTag(inputDict, RegistrationTag) + if err != nil { + return nil, err + } + if err := checkComponents(il, registrationComponents); err != nil { + return nil, err + } + meta, err := readMeta(il) + if err != nil { + return nil, err + } + if len(meta.nonce) < minNonceLen { + return nil, fmt.Errorf("nonce too short") + } + if err := checkClock(meta.created, meta.expires, cfg.Now); err != nil { + return nil, err + } + pub, err := DecodeDIDKey(meta.keyID) + if err != nil { + return nil, err + } + if got := canonicalAuthority(requestAuthority(r)); got != wantAuthority { + return nil, fmt.Errorf("unexpected authority %q, want %q", got, wantAuthority) + } + + sig, err := signatureBytes(sigDict, label) + if err != nil { + return nil, err + } + sigParams, err := httpsfv.Marshal(il) + if err != nil { + return nil, fmt.Errorf("re-marshal signature params: %w", err) + } + comps, err := deriveComponents(r, registrationComponents) + if err != nil { + return nil, err + } + ok, err := pub.Verify([]byte(signatureBase(comps, sigParams)), sig) + if err != nil { + return nil, fmt.Errorf("verifying signature: %w", err) + } + if !ok { + return nil, errors.New("signature verification failed") + } + return &VerifiedRequest{ + PubKey: pub, + KeyID: meta.keyID, + Nonce: meta.nonce, + Created: meta.created, + Expires: meta.expires, + }, nil +} + +// sigMeta holds the RFC 9421 signature parameters carried in the inner list. +type sigMeta struct { + created int64 + expires int64 + nonce string + keyID string + tag string +} + +// componentValue is a covered component and its derived value. +type componentValue struct { + id string + value string +} + +// signatureBase builds the RFC 9421 signature base: one line per covered +// component, then the @signature-params line with no trailing newline. +func signatureBase(components []componentValue, sigParams string) string { + var b strings.Builder + for _, c := range components { + b.WriteByte('"') + b.WriteString(c.id) + b.WriteString(`": `) + b.WriteString(c.value) + b.WriteByte('\n') + } + b.WriteString(`"@signature-params": `) + b.WriteString(sigParams) + return b.String() +} + +// buildInnerList constructs the Signature-Input inner list (covered components +// plus parameters, in fixed order). +func buildInnerList(ids []string, m sigMeta) httpsfv.InnerList { + items := make([]httpsfv.Item, len(ids)) + for i, id := range ids { + items[i] = httpsfv.NewItem(id) + } + params := httpsfv.NewParams() + params.Add("created", m.created) + params.Add("expires", m.expires) + params.Add("nonce", m.nonce) + params.Add("keyid", m.keyID) + params.Add("tag", m.tag) + return httpsfv.InnerList{Items: items, Params: params} +} + +// deriveComponents resolves each covered component id to its value from r. +func deriveComponents(r *http.Request, ids []string) ([]componentValue, error) { + out := make([]componentValue, len(ids)) + for i, id := range ids { + v, err := deriveComponent(r, id) + if err != nil { + return nil, err + } + out[i] = componentValue{id: id, value: v} + } + return out, nil +} + +func deriveComponent(r *http.Request, id string) (string, error) { + switch id { + case "@method": + return strings.ToUpper(r.Method), nil + case "@authority": + return canonicalAuthority(requestAuthority(r)), nil + case "@path": + p := r.URL.EscapedPath() + if p == "" { + p = "/" + } + return p, nil + default: + vals := r.Header.Values(id) + if len(vals) == 0 { + return "", fmt.Errorf("signature base: missing covered header %q", id) + } + return strings.TrimSpace(strings.Join(vals, ", ")), nil + } +} + +func requestAuthority(r *http.Request) string { + if r.Host != "" { + return r.Host + } + return r.URL.Host +} + +// canonicalAuthority lowercases an authority and strips a default http/https +// port, matching how both signer and verifier must render @authority. +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 +} + +// selectByTag returns the single inner-list member whose tag parameter matches +// want. More than one match, or none, is an error. +func selectByTag(d *httpsfv.Dictionary, want string) (string, httpsfv.InnerList, error) { + var ( + found bool + label string + il httpsfv.InnerList + ) + for _, name := range d.Names() { + member, _ := d.Get(name) + list, ok := member.(httpsfv.InnerList) + if !ok { + continue + } + tag, _ := list.Params.Get("tag") + if ts, _ := tag.(string); ts == want { + if found { + return "", httpsfv.InnerList{}, fmt.Errorf("multiple signatures tagged %q", want) + } + found, label, il = true, name, list + } + } + if !found { + return "", httpsfv.InnerList{}, fmt.Errorf("no signature tagged %q", want) + } + return label, il, nil +} + +// checkComponents verifies the inner list covers exactly want, in order, with +// no per-component parameters. +func checkComponents(il httpsfv.InnerList, want []string) error { + if len(il.Items) != len(want) { + return fmt.Errorf("covered components: got %d, want %d", len(il.Items), len(want)) + } + for i, item := range il.Items { + if len(item.Params.Names()) != 0 { + return fmt.Errorf("covered component %q must not carry parameters", want[i]) + } + got, ok := item.Value.(string) + if !ok { + return fmt.Errorf("covered component %d is not a string", i) + } + if got != want[i] { + return fmt.Errorf("covered component %d: got %q, want %q", i, got, want[i]) + } + } + return nil +} + +// readMeta extracts and type-checks the required signature parameters. +func readMeta(il httpsfv.InnerList) (sigMeta, error) { + var m sigMeta + var err error + if m.created, err = intParam(il.Params, "created"); err != nil { + return m, err + } + if m.expires, err = intParam(il.Params, "expires"); err != nil { + return m, err + } + if m.nonce, err = strParam(il.Params, "nonce"); err != nil { + return m, err + } + if m.keyID, err = strParam(il.Params, "keyid"); err != nil { + return m, err + } + if m.tag, err = strParam(il.Params, "tag"); err != nil { + return m, err + } + return m, nil +} + +func intParam(p *httpsfv.Params, name string) (int64, error) { + v, ok := p.Get(name) + if !ok { + return 0, fmt.Errorf("missing signature parameter %q", name) + } + i, ok := v.(int64) + if !ok { + return 0, fmt.Errorf("signature parameter %q is not an integer", name) + } + return i, nil +} + +func strParam(p *httpsfv.Params, name string) (string, error) { + v, ok := p.Get(name) + if !ok { + return "", fmt.Errorf("missing signature parameter %q", name) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("signature parameter %q is not a string", name) + } + return s, nil +} + +func checkClock(created, expires int64, now time.Time) error { + if created == 0 || expires == 0 { + return errors.New("created and expires are required") + } + if expires <= created { + return errors.New("expires must be after created") + } + if expires-created > int64(MaxSignatureLifetime/time.Second) { + return fmt.Errorf("signature lifetime exceeds %s", MaxSignatureLifetime) + } + ct, et := time.Unix(created, 0), time.Unix(expires, 0) + if ct.After(now.Add(MaxForwardDrift)) { + return errors.New("created is too far in the future") + } + if et.Before(now.Add(-MaxClockSkew)) { + return errors.New("signature has expired") + } + return nil +} + +func signatureBytes(d *httpsfv.Dictionary, label string) ([]byte, error) { + member, ok := d.Get(label) + if !ok { + return nil, fmt.Errorf("Signature missing label %q", label) + } + item, ok := member.(httpsfv.Item) + if !ok { + return nil, fmt.Errorf("Signature %q is not an item", label) + } + b, ok := item.Value.([]byte) + if !ok { + return nil, fmt.Errorf("Signature %q is not a byte sequence", label) + } + return b, nil +} diff --git a/internal/httpsig/httpsig_test.go b/internal/httpsig/httpsig_test.go new file mode 100644 index 0000000..a119e2f --- /dev/null +++ b/internal/httpsig/httpsig_test.go @@ -0,0 +1,278 @@ +package httpsig + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "net/http" + "strings" + "testing" + "time" + + "github.com/dunglas/httpsfv" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" +) + +const ( + testAuthority = "registration.libp2p.direct" + testURL = "https://registration.libp2p.direct/v2/_acme-challenge" +) + +// fixedKey returns a deterministic Ed25519 key so signatures are reproducible. +func fixedKey(t *testing.T, seed byte) crypto.PrivKey { + t.Helper() + src := bytes.Repeat([]byte{seed}, 64) + priv, _, err := crypto.GenerateEd25519Key(bytes.NewReader(src)) + require.NoError(t, err) + return priv +} + +func newRequest(t *testing.T, body []byte) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodPost, testURL, bytes.NewReader(body)) + require.NoError(t, err) + return req +} + +func TestSignVerifyRoundTrip(t *testing.T) { + priv := fixedKey(t, 0x01) + body := []byte(`{"value":"3q2-7w","addresses":["/dns4/example.com/tcp/443/tls/http"]}`) + req := newRequest(t, body) + + now := time.Unix(1_700_000_000, 0) + p, err := NewSignParams(now, time.Minute) + require.NoError(t, err) + require.NoError(t, SignRequest(req, priv, body, p)) + + got, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) + require.NoError(t, err) + + wantID, err := EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + require.Equal(t, wantID, got.KeyID) + require.True(t, got.PubKey.Equals(priv.GetPublic())) + require.Equal(t, p.Nonce, got.Nonce) +} + +// TestSignatureBaseGolden pins the exact RFC 9421 signature base bytes so a +// canonicalization change cannot pass unnoticed and cross-language signers can +// reproduce it. +func TestSignatureBaseGolden(t *testing.T) { + priv := fixedKey(t, 0x01) + keyID, err := EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + + body := []byte(`{"value":"abc"}`) + req := newRequest(t, body) + cd, err := ContentDigest(body) + require.NoError(t, err) + req.Header.Set(contentDigestHeader, cd) + req.Header.Set("Content-Type", "application/json") + + il := buildInnerList(registrationComponents, sigMeta{ + created: 1_700_000_000, + expires: 1_700_000_060, + nonce: "dGVzdG5vbmNlMTIzNA", + keyID: keyID, + tag: RegistrationTag, + }) + sigParams, err := httpsfv.Marshal(il) + require.NoError(t, err) + comps, err := deriveComponents(req, registrationComponents) + require.NoError(t, err) + base := signatureBase(comps, sigParams) + + sum := sha256.Sum256(body) + wantDigest := "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" + require.Equal(t, wantDigest, cd, "Content-Digest format") + + want := strings.Join([]string{ + `"@method": POST`, + `"@authority": registration.libp2p.direct`, + `"@path": /v2/_acme-challenge`, + `"content-type": application/json`, + `"content-digest": ` + wantDigest, + `"@signature-params": ("@method" "@authority" "@path" "content-type" "content-digest");created=1700000000;expires=1700000060;nonce="dGVzdG5vbmNlMTIzNA";keyid="` + keyID + `";tag="p2p-forge-reg"`, + }, "\n") + require.Equal(t, want, base) +} + +func TestVerifyRejectsTamper(t *testing.T) { + priv := fixedKey(t, 0x02) + body := []byte(`{"value":"xyz"}`) + now := time.Unix(1_700_000_000, 0) + + sign := func() *http.Request { + req := newRequest(t, body) + p, err := NewSignParams(now, time.Minute) + require.NoError(t, err) + require.NoError(t, SignRequest(req, priv, body, p)) + return req + } + + t.Run("body changed", func(t *testing.T) { + req := sign() + _, err := VerifyRequest(req, []byte(`{"value":"XYZ"}`), VerifyConfig{Authority: testAuthority, Now: now}) + require.ErrorContains(t, err, "content-digest") + }) + + t.Run("method changed", func(t *testing.T) { + req := sign() + req.Method = http.MethodPut + _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) + require.ErrorContains(t, err, "signature verification failed") + }) + + t.Run("covered header changed", func(t *testing.T) { + req := sign() + req.Header.Set("Content-Type", "text/plain") + _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) + require.ErrorContains(t, err, "signature verification failed") + }) + + t.Run("wrong authority", func(t *testing.T) { + req := sign() + _, err := VerifyRequest(req, body, VerifyConfig{Authority: "evil.example", Now: now}) + require.ErrorContains(t, err, "unexpected authority") + }) + + t.Run("signature bytes flipped", func(t *testing.T) { + req := sign() + sig := req.Header.Get("Signature") + req.Header.Set("Signature", strings.Replace(sig, "sig1=:", "sig1=:AA", 1)) + _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) + require.Error(t, err) + }) +} + +func TestVerifyClockWindow(t *testing.T) { + priv := fixedKey(t, 0x03) + body := []byte(`{}`) + signAt := time.Unix(1_700_000_000, 0) + + req := newRequest(t, body) + p, err := NewSignParams(signAt, time.Minute) + require.NoError(t, err) + require.NoError(t, SignRequest(req, priv, body, p)) + + t.Run("expired", func(t *testing.T) { + _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: signAt.Add(MaxClockSkew + 2*time.Minute)}) + require.ErrorContains(t, err, "expired") + }) + + t.Run("created in future", func(t *testing.T) { + _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: signAt.Add(-time.Hour)}) + require.ErrorContains(t, err, "future") + }) + + t.Run("within skew ok", func(t *testing.T) { + _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: signAt.Add(30 * time.Second)}) + require.NoError(t, err) + }) +} + +func TestVerifyRejectsWrongProfile(t *testing.T) { + priv := fixedKey(t, 0x04) + body := []byte(`{}`) + now := time.Unix(1_700_000_000, 0) + + t.Run("wrong tag", func(t *testing.T) { + req := newRequest(t, body) + cd, err := ContentDigest(body) + require.NoError(t, err) + req.Header.Set(contentDigestHeader, cd) + req.Header.Set("Content-Type", "application/json") + il := buildInnerList(registrationComponents, sigMeta{ + created: now.Unix(), expires: now.Add(time.Minute).Unix(), + nonce: "dGVzdG5vbmNlMTIzNA", keyID: mustDID(t, priv), tag: "some-other-service", + }) + signInto(t, req, priv, il) + _, err = VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) + require.ErrorContains(t, err, "no signature tagged") + }) + + t.Run("missing covered component", func(t *testing.T) { + req := newRequest(t, body) + cd, err := ContentDigest(body) + require.NoError(t, err) + req.Header.Set(contentDigestHeader, cd) + req.Header.Set("Content-Type", "application/json") + shortSet := []string{"@method", "@authority", "@path", "content-digest"} // drops content-type + il := buildInnerList(shortSet, sigMeta{ + created: now.Unix(), expires: now.Add(time.Minute).Unix(), + nonce: "dGVzdG5vbmNlMTIzNA", keyID: mustDID(t, priv), tag: RegistrationTag, + }) + signInto(t, req, priv, il) + _, err = VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) + require.ErrorContains(t, err, "covered components") + }) +} + +func TestVerifyAuthority(t *testing.T) { + priv := fixedKey(t, 0x05) + body := []byte(`{}`) + now := time.Unix(1_700_000_000, 0) + req := newRequest(t, body) + p, err := NewSignParams(now, time.Minute) + require.NoError(t, err) + require.NoError(t, SignRequest(req, priv, body, p)) + + t.Run("empty configured authority is a misconfig", func(t *testing.T) { + _, err := VerifyRequest(req, body, VerifyConfig{Authority: "", Now: now}) + require.ErrorContains(t, err, "empty authority") + }) + + t.Run("configured authority is canonicalized", func(t *testing.T) { + // Uppercase + explicit default port must still match the request. + _, err := VerifyRequest(req, body, VerifyConfig{Authority: strings.ToUpper(testAuthority) + ":443", Now: now}) + require.NoError(t, err) + }) +} + +func TestContentDigestVerify(t *testing.T) { + body := []byte("hello world") + cd, err := ContentDigest(body) + require.NoError(t, err) + require.NoError(t, verifyContentDigest(cd, body)) + require.Error(t, verifyContentDigest(cd, []byte("hello world!"))) + require.ErrorContains(t, verifyContentDigest(`md5=:xxxx:`, body), "sha-256") +} + +// mustDID and signInto are test helpers that build a signature from a +// caller-supplied inner list so tests can exercise malformed profiles. +func mustDID(t *testing.T, priv crypto.PrivKey) string { + t.Helper() + id, err := EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + return id +} + +// signInto signs req with a caller-supplied inner list, so tests can craft +// malformed profiles (wrong tag, missing component) that SignRequest would not +// produce. +func signInto(t *testing.T, req *http.Request, priv crypto.PrivKey, il httpsfv.InnerList) { + t.Helper() + sigParams, err := httpsfv.Marshal(il) + require.NoError(t, err) + ids := make([]string, len(il.Items)) + for i, item := range il.Items { + ids[i] = item.Value.(string) + } + comps, err := deriveComponents(req, ids) + require.NoError(t, err) + sig, err := priv.Sign([]byte(signatureBase(comps, sigParams))) + require.NoError(t, err) + + inputDict := httpsfv.NewDictionary() + inputDict.Add(sigLabel, il) + inputStr, err := httpsfv.Marshal(inputDict) + require.NoError(t, err) + req.Header.Set("Signature-Input", inputStr) + + sigDict := httpsfv.NewDictionary() + sigDict.Add(sigLabel, httpsfv.NewItem(sig)) + sigStr, err := httpsfv.Marshal(sigDict) + require.NoError(t, err) + req.Header.Set("Signature", sigStr) +} From fd8b6d66e6b88fbdcc262bf65ccf64f67c4440ac Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 13:04:52 +0200 Subject: [PATCH 02/27] feat(acme): harden the reachability dialback against SSRF The dialback dials addresses an authenticated client supplies, so it must never reach internal, link-local, or metadata targets. Add a shared destination-IP vetter and pin the vetted IPs through a libp2p connection gater so neither TCP nor QUIC can be steered off them. - vetDestIP rejects non-global-unicast destinations: loopback, unspecified, 0.0.0.0/8, RFC1918, CGNAT, link-local, ULA, multicast, benchmarking, Class E, and the IPv4-embedding v6 ranges (IPv4-mapped, IPv4-compatible, NAT64, 6to4, Teredo), unmapping v4-in-v6 first - resolveAndVet resolves /dns* to concrete IPs, drops relay and non-public addresses, and bounds dial fan-out after resolution; the probe dials only concrete pinned IPs, closing the DNS-rebinding gap - the probe now runs under a timeout, caps the address count, reads the agent version from the identify event instead of a racy peerstore get, denylist-checks resolved IPs (which the literal-only check missed), and returns a generic reachability error so it cannot be used to port-scan - new registration-domain option allow-private-addresses (default false) turns vetting off for tests and private deployments Both v1 and v2 registration share the hardened path. --- acme/dialguard.go | 294 +++++++++++++++++++++++++++++++++++++++++ acme/dialguard_test.go | 114 ++++++++++++++++ acme/setup.go | 20 ++- acme/writer.go | 46 +------ acme/writer_v2.go | 2 +- acme/writer_v2_test.go | 2 + e2e_test.go | 4 +- 7 files changed, 433 insertions(+), 49 deletions(-) create mode 100644 acme/dialguard.go create mode 100644 acme/dialguard_test.go diff --git a/acme/dialguard.go b/acme/dialguard.go new file mode 100644 index 0000000..a17d0f6 --- /dev/null +++ b/acme/dialguard.go @@ -0,0 +1,294 @@ +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/event" + "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 single reachability probe so a slow or blackholed +// target cannot pin a request goroutine and its libp2p host. +const probeTimeout = 15 * time.Second + +// identifyWait bounds how long we wait for the identify exchange to report the +// peer's agent version (a metric label only). +const identifyWait = 3 * time.Second + +// maxProbeAddresses bounds how many addresses one registration may ask the +// forge to dial, limiting dial fan-out and reflection. It is 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("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("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{}{} + ) +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 + } + + 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 dial +// with a timeout. +func (c *acmeWriter) testAddresses(ctx context.Context, p peer.ID, addrStrs []string, httpUserAgent string) error { + agentVersion := agentType(httpUserAgent) + + if !c.AllowPrivateAddrs && len(addrStrs) > maxProbeAddresses { + recordPeerProbe("error", agentVersion) + return fmt.Errorf("too many addresses (%d > %d)", len(addrStrs), maxProbeAddresses) + } + + 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()} + dialCtx := ctx + if !c.AllowPrivateAddrs { + var cancel context.CancelFunc + dialCtx, cancel = context.WithTimeout(ctx, probeTimeout) + defer cancel() + opts = append(opts, libp2p.ConnectionGater(newIPGater(ips))) + } + + h, err := libp2p.New(opts...) + if err != nil { + recordPeerProbe("error", agentVersion) + return err + } + defer h.Close() + + // Subscribe before connecting so identify completion is observed + // deterministically instead of racing a peerstore read. + sub, subErr := h.EventBus().Subscribe(new(event.EvtPeerIdentificationCompleted)) + if subErr == nil { + defer sub.Close() + } + + if err := h.Connect(dialCtx, 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") + } + + agentVersion = identifiedAgent(dialCtx, sub, p, agentVersion) + log.Debugf("connected to peer %s - UserAgent: %q", p, agentVersion) + recordPeerProbe("ok", agentType(agentVersion)) + return nil +} + +// identifiedAgent waits briefly for the identify exchange with p to complete and +// returns its reported agent version, falling back to the passed value. +func identifiedAgent(ctx context.Context, sub event.Subscription, p peer.ID, fallback string) string { + if sub == nil { + return fallback + } + timer := time.NewTimer(identifyWait) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return fallback + case <-timer.C: + return fallback + case e, ok := <-sub.Out(): + if !ok { + return fallback + } + ev, ok := e.(event.EvtPeerIdentificationCompleted) + if ok && ev.Peer == p { + if ev.AgentVersion != "" { + return ev.AgentVersion + } + return fallback + } + } + } +} diff --git a/acme/dialguard_test.go b/acme/dialguard_test.go new file mode 100644 index 0000000..26d7831 --- /dev/null +++ b/acme/dialguard_test.go @@ -0,0 +1,114 @@ +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 + } + 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/setup.go b/acme/setup.go index 8f4613f..b57a5b3 100644 --- a/acme/setup.go +++ b/acme/setup.go @@ -61,6 +61,7 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { var forgeDomain string var forgeRegistrationDomain string var externalTLS bool + var allowPrivateAddrs bool var httpListenAddr string var ds datastore.TTLDatastore @@ -80,7 +81,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,6 +103,12 @@ 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() } @@ -162,11 +169,12 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { initMetrics() writer := &acmeWriter{ - Addr: httpListenAddr, - Domain: forgeRegistrationDomain, - ForgeDomain: forgeDomain, - Datastore: ds, - ExternalTLS: externalTLS, + Addr: httpListenAddr, + Domain: forgeRegistrationDomain, + ForgeDomain: forgeDomain, + Datastore: ds, + ExternalTLS: externalTLS, + AllowPrivateAddrs: allowPrivateAddrs, } reader := &acmeReader{ ForgeDomain: forgeDomain, diff --git a/acme/writer.go b/acme/writer.go index d098a15..a8018b0 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -31,11 +31,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) @@ -50,6 +48,11 @@ type acmeWriter struct { ForgeDomain string ExternalTLS bool + // AllowPrivateAddrs disables destination-IP vetting, the address cap, and + // the dial timeout on the reachability probe. Off by default; intended for + // tests and private deployments that trust the submitted addresses. + AllowPrivateAddrs bool + Datastore datastore.TTLDatastore ln net.Listener @@ -162,7 +165,7 @@ func (c *acmeWriter) OnStartup() error { } 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 @@ -241,43 +244,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 diff --git a/acme/writer_v2.go b/acme/writer_v2.go index ce024e7..d22ce75 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -97,7 +97,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { // "Real node" check. For now this is the (unchanged) libp2p dialback; the // hardened dialback and the http-ownership proof land in later commits. - if err := testAddresses(r.Context(), peerID, typedBody.Addresses, r.Header.Get("User-Agent")); err != nil { + if err := c.testAddresses(r.Context(), peerID, typedBody.Addresses, r.Header.Get("User-Agent")); err != nil { writeProblem(w, http.StatusUnprocessableEntity, "verification-failed", fmt.Sprintf("no address verified: %s", err)) return } diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index 761de5e..4d9fceb 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -74,6 +74,8 @@ func newTestWriter() *acmeWriter { Domain: v2TestDomain, ForgeDomain: "libp2p.direct", Datastore: ttlDatastore{datastore.NewMapDatastore()}, + // Tests dial loopback hosts, which destination-IP vetting would reject. + AllowPrivateAddrs: true, } } diff --git a/e2e_test.go b/e2e_test.go index 84f1612..7148565 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) @@ -1104,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) From 3390cc9e856aad972771f651e7393be0154e4d9f Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 13:12:57 +0200 Subject: [PATCH 03/27] feat(acme): replay guard and rate limiting for v2 Protect /v2 from replay of a captured signed request and from registration floods, and stop trusting a spoofable X-Forwarded-For. - a nonce store records each (peer, nonce) in the shared datastore with a TTL longer than the signature validity window, so a replay within that window is rejected; reservation is atomic per instance and fails closed - a per-source-IP token bucket runs before the signature verify, returns Retry-After on 429, and evicts idle buckets - clientIPs no longer trusts a leftmost X-Forwarded-For, which any client can forge; it honors the direct address plus an operator-named trusted header via the new client-ip-header option - startup asserts the nonce TTL outlives the max signature lifetime plus clock skew --- acme/antiabuse.go | 163 +++++++++++++++++++++++++++++++++++++++++ acme/antiabuse_test.go | 111 ++++++++++++++++++++++++++++ acme/clientip.go | 31 ++++---- acme/clientip_test.go | 76 +++++++++---------- acme/setup.go | 8 ++ acme/writer.go | 12 ++- acme/writer_v2.go | 22 +++++- acme/writer_v2_test.go | 4 +- go.mod | 2 +- 9 files changed, 364 insertions(+), 65 deletions(-) create mode 100644 acme/antiabuse.go create mode 100644 acme/antiabuse_test.go diff --git a/acme/antiabuse.go b/acme/antiabuse.go new file mode 100644 index 0000000..dcd6ee1 --- /dev/null +++ b/acme/antiabuse.go @@ -0,0 +1,163 @@ +package acme + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "strings" + "sync" + "time" + + "github.com/ipfs/go-datastore" + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/libp2p/go-libp2p/core/peer" + "golang.org/x/time/rate" +) + +// Anti-abuse tunables. Exported-style named constants so docs and operators can +// reference them rather than a drifting literal. +const ( + // nonceTTL is how long a used nonce is remembered. It must exceed the + // maximum signature lifetime plus clock skew on both sides so a nonce + // cannot expire from the store while its signature is still valid. + nonceTTL = 20 * time.Minute + + // registrationsPerMinute and registrationBurst bound registrations per + // source IP. A cert order needs ~2 requests, so this is generous for + // legitimate renewals while capping abuse. + registrationsPerMinute = 10 + registrationBurst = 20 + + // rateLimiterEntryTTL evicts idle per-IP limiters so the map stays bounded. + rateLimiterEntryTTL = 15 * time.Minute +) + +// errReplay signals a nonce that has already been used. +var errReplay = errors.New("nonce already used") + +// initAntiAbuse constructs the per-instance rate limiter and the nonce store. +// It is idempotent and safe to call from OnStartup and from tests. +func (c *acmeWriter) initAntiAbuse() { + // A nonce must outlive the longest window in which its signature is still + // valid, or a replay could land after the nonce expired from the store. + if min := httpsig.MaxSignatureLifetime + 2*httpsig.MaxClockSkew; nonceTTL <= min { + panic(fmt.Sprintf("nonceTTL (%s) must exceed max signature lifetime + 2x skew (%s)", nonceTTL, min)) + } + if c.rateLimiter == nil { + c.rateLimiter = newIPRateLimiter(registrationsPerMinute, registrationBurst, rateLimiterEntryTTL) + } + if c.nonces == nil && c.Datastore != nil { + c.nonces = newNonceStore(c.Datastore, nonceTTL) + } +} + +// primaryClientIP returns the single client IP used for rate limiting: the +// configured trusted header when set (e.g. CF-Connecting-IP behind Cloudflare), +// otherwise the direct connection address. It never trusts a leftmost +// X-Forwarded-For, which any client can forge. +func primaryClientIP(r *http.Request, trustedHeader string) (netip.Addr, bool) { + if trustedHeader != "" { + if v := strings.TrimSpace(r.Header.Get(trustedHeader)); v != "" { + if ip, err := netip.ParseAddr(v); err == nil { + return ip, true + } + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip, err := netip.ParseAddr(host) + return ip, err == nil +} + +// ipRateLimiter is a per-source-IP token-bucket limiter with lazy eviction of +// idle buckets. It is in-memory per instance; behind a load balancer the real +// cap is the front proxy plus the shared datastore-backed guards. +type ipRateLimiter struct { + mu sync.Mutex + buckets map[netip.Addr]*rateBucket + limit rate.Limit + burst int + entryTTL time.Duration + lastGC time.Time +} + +type rateBucket struct { + limiter *rate.Limiter + seen time.Time +} + +func newIPRateLimiter(perMinute, burst int, entryTTL time.Duration) *ipRateLimiter { + return &ipRateLimiter{ + buckets: make(map[netip.Addr]*rateBucket), + limit: rate.Every(time.Minute / time.Duration(perMinute)), + burst: burst, + entryTTL: entryTTL, + } +} + +// allow reports whether a request from ip is permitted at time now. +func (rl *ipRateLimiter) allow(ip netip.Addr, now time.Time) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + if now.Sub(rl.lastGC) > rl.entryTTL { + for k, b := range rl.buckets { + if now.Sub(b.seen) > rl.entryTTL { + delete(rl.buckets, k) + } + } + rl.lastGC = now + } + + b := rl.buckets[ip] + if b == nil { + b = &rateBucket{limiter: rate.NewLimiter(rl.limit, rl.burst)} + rl.buckets[ip] = b + } + b.seen = now + return b.limiter.AllowN(now, 1) +} + +// nonceStore records used nonces so a captured signed request cannot be +// replayed within its validity window. Reservation is atomic within one +// instance via the mutex; across load-balanced instances two truly simultaneous +// replays can both pass, which is low-harm because the resulting write is +// idempotent. It fails closed if the datastore is unavailable. +type nonceStore struct { + ds datastore.TTLDatastore + ttl time.Duration + mu sync.Mutex +} + +func newNonceStore(ds datastore.TTLDatastore, ttl time.Duration) *nonceStore { + return &nonceStore{ds: ds, ttl: ttl} +} + +// reserve records (peerID, nonce) as used, returning errReplay if it was already +// present. The peer.ID is a server-derived internal key, never on the wire. +func (n *nonceStore) reserve(ctx context.Context, peerID peer.ID, nonce string) error { + sum := sha256.Sum256([]byte(nonce)) + key := datastore.NewKey("/v2/nonce/" + peerID.String() + "/" + hex.EncodeToString(sum[:])) + + n.mu.Lock() + defer n.mu.Unlock() + + has, err := n.ds.Has(ctx, key) + if err != nil { + return fmt.Errorf("nonce store unavailable: %w", err) + } + if has { + return errReplay + } + if err := n.ds.PutWithTTL(ctx, key, []byte{}, n.ttl); err != nil { + return fmt.Errorf("nonce store write failed: %w", err) + } + return nil +} diff --git a/acme/antiabuse_test.go b/acme/antiabuse_test.go new file mode 100644 index 0000000..e5ce1f1 --- /dev/null +++ b/acme/antiabuse_test.go @@ -0,0 +1,111 @@ +package acme + +import ( + "bytes" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + "time" + + "github.com/ipfs/go-datastore" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/stretchr/testify/require" +) + +func TestIPRateLimiter(t *testing.T) { + rl := newIPRateLimiter(60, 2, time.Minute) // 1/sec, burst 2 + ip := netip.MustParseAddr("203.0.113.4") + now := time.Unix(1_700_000_000, 0) + + require.True(t, rl.allow(ip, now)) + require.True(t, rl.allow(ip, now)) + require.False(t, rl.allow(ip, now), "burst exhausted") + require.True(t, rl.allow(ip, now.Add(time.Second)), "refilled after 1s") + + // A different IP has its own bucket. + require.True(t, rl.allow(netip.MustParseAddr("198.51.100.9"), now)) +} + +func TestNonceStoreReplay(t *testing.T) { + ns := newNonceStore(ttlDatastore{datastore.NewMapDatastore()}, time.Minute) + _, priv := newRegistrantHost(t) + pid, err := peer.IDFromPublicKey(priv.GetPublic()) + require.NoError(t, err) + + require.NoError(t, ns.reserve(t.Context(), pid, "nonce-abc")) + require.ErrorIs(t, ns.reserve(t.Context(), pid, "nonce-abc"), errReplay) + require.NoError(t, ns.reserve(t.Context(), pid, "nonce-xyz"), "different nonce is fresh") +} + +func TestPrimaryClientIP(t *testing.T) { + t.Run("no trusted header uses RemoteAddr, ignores XFF", func(t *testing.T) { + r := &http.Request{Header: http.Header{"X-Forwarded-For": {"6.6.6.6"}}, RemoteAddr: "9.9.9.9:80"} + ip, ok := primaryClientIP(r, "") + require.True(t, ok) + require.Equal(t, "9.9.9.9", ip.String()) + }) + t.Run("trusted header wins", func(t *testing.T) { + r := &http.Request{Header: http.Header{"Cf-Connecting-Ip": {"1.2.3.4"}}, RemoteAddr: "9.9.9.9:80"} + ip, ok := primaryClientIP(r, "CF-Connecting-IP") + require.True(t, ok) + require.Equal(t, "1.2.3.4", ip.String()) + }) +} + +func TestV2NonceReplayRejected(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 := readAndRestore(signed) + require.NoError(t, err) + header := signed.Header.Clone() + + replay := 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, replay().Code, "first submission succeeds") + require.Equal(t, http.StatusConflict, replay().Code, "replay of the same nonce is rejected") +} + +func TestV2RateLimited(t *testing.T) { + initMetrics() + c := newTestWriter() + c.rateLimiter = newIPRateLimiter(1, 1, time.Minute) // burst 1 + + // Requests need no valid signature: the rate limit runs before verification. + mk := func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", nil) + r.Host = v2TestDomain + r.RemoteAddr = "203.0.113.7:2222" + return r + } + + first := httptest.NewRecorder() + c.handleV2Challenge(first, mk()) + require.NotEqual(t, http.StatusTooManyRequests, first.Code, "first passes the limiter") + + second := httptest.NewRecorder() + c.handleV2Challenge(second, mk()) + require.Equal(t, http.StatusTooManyRequests, second.Code) + require.NotEmpty(t, second.Header().Get("Retry-After")) +} + +// readAndRestore reads a request body and refills it so the request stays usable. +func readAndRestore(r *http.Request) ([]byte, error) { + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(r.Body); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/acme/clientip.go b/acme/clientip.go index 16729cc..ebcd287 100644 --- a/acme/clientip.go +++ b/acme/clientip.go @@ -9,30 +9,25 @@ 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, err := netip.ParseAddr(v); err == nil { + ips = append(ips, ip) + } } } - // Also check RemoteAddr (direct connection IP) host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr diff --git a/acme/clientip_test.go b/acme/clientip_test.go index 2df8e8b..7241179 100644 --- a/acme/clientip_test.go +++ b/acme/clientip_test.go @@ -9,65 +9,55 @@ 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: "empty headers", - xff: "", - remoteAddr: "", - expected: nil, - }, - { - name: "XFF with spaces", - xff: " 1.2.3.4 ", - remoteAddr: "", + name: "RemoteAddr without port", + remoteAddr: "1.2.3.4", expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")}, }, { - name: "invalid XFF skipped", - xff: "not-an-ip", - remoteAddr: "1.2.3.4:80", - expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")}, + 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: "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 +67,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/setup.go b/acme/setup.go index b57a5b3..549d3a3 100644 --- a/acme/setup.go +++ b/acme/setup.go @@ -62,6 +62,7 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { var forgeRegistrationDomain string var externalTLS bool var allowPrivateAddrs bool + var clientIPHeader string var httpListenAddr string var ds datastore.TTLDatastore @@ -113,6 +114,12 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { return nil, nil, c.ArgErr() } } + case "client-ip-header": + args := c.RemainingArgs() + if len(args) != 1 { + return nil, nil, c.ArgErr() + } + clientIPHeader = args[0] case "database-type": args := c.RemainingArgs() if len(args) == 0 { @@ -175,6 +182,7 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { Datastore: ds, ExternalTLS: externalTLS, AllowPrivateAddrs: allowPrivateAddrs, + ClientIPHeader: clientIPHeader, } reader := &acmeReader{ ForgeDomain: forgeDomain, diff --git a/acme/writer.go b/acme/writer.go index a8018b0..291ff85 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -53,8 +53,16 @@ type acmeWriter struct { // 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 + rateLimiter *ipRateLimiter + nonces *nonceStore + ln net.Listener nlSetup bool closeCertMgr func() @@ -102,6 +110,8 @@ func (c *acmeWriter) OnStartup() error { c.ln = ln c.nlSetup = true + c.initAntiAbuse() + // server side secret key and peerID not particularly relevant, so we can generate new ones as needed sk, _, err := crypto.GenerateEd25519Key(rand.Reader) if err != nil { @@ -158,7 +168,7 @@ 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 diff --git a/acme/writer_v2.go b/acme/writer_v2.go index d22ce75..565d9ea 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -52,6 +52,13 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } + // Per-source-IP rate limit, ahead of the asymmetric signature verify. + if ip, ok := primaryClientIP(r, c.ClientIPHeader); ok && c.rateLimiter != nil && !c.rateLimiter.allow(ip, time.Now()) { + w.Header().Set("Retry-After", "60") + writeProblem(w, http.StatusTooManyRequests, "rate-limited", "too many registrations from your address") + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxV2BodySize)) if err != nil { var maxErr *http.MaxBytesError @@ -80,6 +87,19 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } + // Single-use nonce: reject a replayed signed request before the dialback. + if c.nonces != nil { + if err := c.nonces.reserve(r.Context(), peerID, verified.Nonce); err != nil { + if errors.Is(err, errReplay) { + writeProblem(w, http.StatusConflict, "nonce-replayed", "this request has already been submitted") + } else { + writeProblem(w, http.StatusInternalServerError, "nonce-store-error", "could not verify request freshness") + log.Errorf("v2: nonce store error for %s: %v", peerID, err) + } + return + } + } + typedBody, err := decodeV2Body(body) if err != nil { writeProblem(w, http.StatusBadRequest, "malformed-body", err.Error()) @@ -90,7 +110,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } - if blocked, reason := checkDenylist(clientIPs(r), typedBody.Addresses); blocked { + if blocked, reason := checkDenylist(clientIPs(r, c.ClientIPHeader), typedBody.Addresses); blocked { writeProblem(w, http.StatusForbidden, "denylisted", reason) return } diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index 4d9fceb..93e07ef 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -70,13 +70,15 @@ func addrStrings(h host.Host) []string { } func newTestWriter() *acmeWriter { - return &acmeWriter{ + c := &acmeWriter{ Domain: v2TestDomain, ForgeDomain: "libp2p.direct", Datastore: ttlDatastore{datastore.NewMapDatastore()}, // Tests dial loopback hosts, which destination-IP vetting would reject. AllowPrivateAddrs: true, } + c.initAntiAbuse() + return c } func TestV2ChallengeHandlerRoundTrip(t *testing.T) { diff --git a/go.mod b/go.mod index 2af7476..9fa04b0 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/slok/go-http-metrics v0.13.0 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 + golang.org/x/time v0.15.0 ) require ( @@ -158,7 +159,6 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect golang.org/x/text v0.36.0 // indirect - golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect From 7bcc4834ee4554af2fc93364de8f0b02af213a16 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 13:32:02 +0200 Subject: [PATCH 04/27] feat: add http-ownership proof for v2 Let a node prove key control over a plain HTTP(S) endpoint instead of a libp2p dialback, so a registrant needs no libp2p on either leg. - internal/httpsig: SignOwnership/VerifyOwnership build and check a static RFC 9421 proof binding the key to a canonical origin, verified under the registration keyid (never the proof's own named key), with a bounded validity window - client: OwnershipProofHandler serves the cached, offline-signable proof at /.well-known/p2p-forge/, and CanonicalOrigin parses an origin-only URL - acme: verifyReachable picks http-ownership for http(s) addresses and the libp2p dialback otherwise; the fetch resolves and pins the endpoint IP, refuses non-public targets and non-80/443 ports, disables redirects, caps the body, verifies WebPKI when a valid cert exists and falls back to the pinned-IP plus signature path when it does not, and bounds all reachability work with one deadline --- acme/ownership.go | 217 +++++++++++++++++++++++++++++ acme/ownership_test.go | 116 +++++++++++++++ acme/writer_v2.go | 21 +-- client/ownership.go | 138 ++++++++++++++++++ internal/httpsig/ownership.go | 215 ++++++++++++++++++++++++++++ internal/httpsig/ownership_test.go | 87 ++++++++++++ 6 files changed, 784 insertions(+), 10 deletions(-) create mode 100644 acme/ownership.go create mode 100644 acme/ownership_test.go create mode 100644 client/ownership.go create mode 100644 internal/httpsig/ownership.go create mode 100644 internal/httpsig/ownership_test.go diff --git a/acme/ownership.go b/acme/ownership.go new file mode 100644 index 0000000..f8a2831 --- /dev/null +++ b/acme/ownership.go @@ -0,0 +1,217 @@ +package acme + +import ( + "context" + "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/httpsig" + "github.com/libp2p/go-libp2p/core/peer" +) + +const ( + // ownershipFetchTimeout bounds one proof fetch. + ownershipFetchTimeout = 15 * time.Second + // ownershipBodyLimit caps the proof response body (the body is empty). + ownershipBodyLimit = 4 << 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, keyID string, peerID peer.ID, 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, keyID, httpURLs); err == nil { + return "http-ownership", nil + } else { + lastErr = err + } + } + if len(libp2pAddrs) > 0 { + if err := c.testAddresses(ctx, 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). +func partitionAddrs(addrs []string) (httpURLs, libp2pAddrs []string) { + for _, a := range addrs { + if strings.HasPrefix(a, "http://") || strings.HasPrefix(a, "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. +func (c *acmeWriter) verifyHTTPOwnership(ctx context.Context, keyID string, urls []string) error { + if !c.AllowPrivateAddrs && len(urls) > maxOwnershipURLs { + return fmt.Errorf("too many http addresses (%d > %d)", len(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, keyID, o); err != nil { + lastErr = err + continue + } + return nil + } + if lastErr == nil { + lastErr = errors.New("no http address proved ownership") + } + return lastErr +} + +func (c *acmeWriter) fetchAndVerifyOwnership(ctx context.Context, keyID string, o client.HTTPOrigin) error { + if !c.AllowPrivateAddrs && o.Port != "80" && o.Port != "443" { + return fmt.Errorf("ownership fetch port %s not allowed", o.Port) + } + + ip, err := c.resolvePinnedIP(ctx, o.Host) + if err != nil { + return err + } + if mgr := denylist.GetManager(); mgr != nil { + if denied, res := mgr.Check(ip); denied { + return fmt.Errorf("endpoint IP %s blocked by %s", ip, res.Name) + } + } + + hdr, body, err := fetchOwnershipProof(ctx, o, ip, keyID) + if err != nil { + return err + } + return httpsig.VerifyOwnership(hdr, body, httpsig.OwnershipVerifyConfig{ + KeyID: keyID, + ExpectedOrigin: o.Origin, + Now: time.Now(), + }) +} + +// resolvePinnedIP resolves host to a single vettable public IP to pin the fetch +// to. A literal IP is used directly. Vetting is skipped when AllowPrivateAddrs. +func (c *acmeWriter) resolvePinnedIP(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 netip.Addr{}, err + } + } + return ip.Unmap(), nil + } + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return netip.Addr{}, fmt.Errorf("resolving %s: %w", host, err) + } + for _, ip := range ips { + ip = ip.Unmap() + if c.AllowPrivateAddrs { + return ip, nil + } + if vetDestIP(ip) == nil { + return ip, nil + } + } + return netip.Addr{}, fmt.Errorf("no public IP for %s", host) +} + +// 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. +// For https it first tries WebPKI verification (strong host binding); if that +// fails (e.g. the node has no valid cert yet) it retries without verification, +// where host binding rests on the IP pin and key binding on the signature. +func fetchOwnershipProof(ctx context.Context, o client.HTTPOrigin, ip netip.Addr, keyID string) (http.Header, []byte, error) { + proofURL := o.Origin + client.WellKnownProofPath + keyID + pinned := net.JoinHostPort(ip.String(), o.Port) + + do := func(insecure bool) (http.Header, []byte, error) { + 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: insecure}, + } + 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, nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("ownership endpoint returned %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, ownershipBodyLimit)) + if err != nil { + return nil, nil, fmt.Errorf("reading ownership proof: %w", err) + } + return resp.Header, body, nil + } + + if o.Scheme == "https" { + hdr, body, err := do(false) + if err == nil { + return hdr, body, nil + } + // Retry without verification only when the cert itself failed to verify + // (the node has no valid CA cert yet). Do not retry on a refused + // connection, timeout, or non-200, which would just double the work. + var certErr *tls.CertificateVerificationError + if errors.As(err, &certErr) { + return do(true) + } + return nil, nil, err + } + return do(true) +} diff --git a/acme/ownership_test.go b/acme/ownership_test.go new file mode 100644 index 0000000..6ad239c --- /dev/null +++ b/acme/ownership_test.go @@ -0,0 +1,116 @@ +package acme + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "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" +) + +// 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(), 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 otherDID must fail + // (the served path is priv's did:key, so this 404s or key-mismatches). + require.Error(t, c.verifyHTTPOwnership(t.Context(), 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(), did, []string{bare.URL})) + }) +} + +func TestHTTPOwnershipTLSFallback(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 must fall back from + // WebPKI to the pinned-IP + signature path. + 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(), 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.Mode) +} + +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", + } { + _, 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) +} diff --git a/acme/writer_v2.go b/acme/writer_v2.go index 565d9ea..5e2f2d3 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -115,10 +115,13 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } - // "Real node" check. For now this is the (unchanged) libp2p dialback; the - // hardened dialback and the http-ownership proof land in later commits. - if err := c.testAddresses(r.Context(), peerID, typedBody.Addresses, r.Header.Get("User-Agent")); err != nil { - writeProblem(w, http.StatusUnprocessableEntity, "verification-failed", fmt.Sprintf("no address verified: %s", err)) + // 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.KeyID, peerID, 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 } @@ -129,12 +132,10 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, v2Response{ - DID: verified.KeyID, - Name: certWildcard(peerID, c.ForgeDomain), - Verification: v2Verification{ - Mode: "libp2p-dialback", - }, - TTL: int(time.Hour / time.Second), + DID: verified.KeyID, + Name: certWildcard(peerID, c.ForgeDomain), + Verification: v2Verification{Mode: mode}, + TTL: int(time.Hour / time.Second), }) } diff --git a/client/ownership.go b/client/ownership.go new file mode 100644 index 0000000..7e4ce51 --- /dev/null +++ b/client/ownership.go @@ -0,0 +1,138 @@ +package client + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/ipshipyard/p2p-forge/internal/httpsig" + "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. +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") + } + port := u.Port() + if port == "" { + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + return HTTPOrigin{ + Origin: u.Scheme + "://" + host + ":" + port, + Scheme: u.Scheme, + Host: host, + Port: port, + }, nil +} + +// OwnershipProofHandler returns an http.Handler that serves the node's ownership +// proof at WellKnownProofPath+ for the given origin. Mount it on the +// node's existing HTTP server. The signed proof is cached and refreshed before +// it expires, so it stays cacheable and the signing 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()) + if err != nil { + return nil, err + } + signer := &ownershipSigner{priv: privKey, origin: o.Origin, window: httpsig.DefaultOwnershipWindow} + 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 + } + hdr, err := signer.headers(time.Now()) + if err != nil { + http.Error(w, "could not produce ownership proof", http.StatusInternalServerError) + return + } + for k, vs := range hdr { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(http.StatusOK) + }), nil +} + +// ownershipSigner caches a signed proof and re-signs it before expiry. +type ownershipSigner struct { + priv crypto.PrivKey + origin string + window time.Duration + + mu sync.Mutex + cached http.Header + expires time.Time +} + +func (s *ownershipSigner) headers(now time.Time) (http.Header, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Refresh once we are into the last quarter of the window. + if s.cached == nil || now.After(s.expires.Add(-s.window/4)) { + created := now.Add(-1 * time.Minute) // small backdate for verifier clock skew + expires := now.Add(s.window) + h, err := httpsig.SignOwnership(s.priv, s.origin, created.Unix(), expires.Unix()) + if err != nil { + return nil, err + } + s.cached = h + s.expires = expires + } + return s.cached, nil +} diff --git a/internal/httpsig/ownership.go b/internal/httpsig/ownership.go new file mode 100644 index 0000000..4493928 --- /dev/null +++ b/internal/httpsig/ownership.go @@ -0,0 +1,215 @@ +package httpsig + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/dunglas/httpsfv" + "github.com/libp2p/go-libp2p/core/crypto" +) + +// OwnershipOriginHeader carries the canonical origin (scheme://host[:port]) the +// proof is bound to. Binding the scheme means an http proof cannot satisfy an +// https address, and vice versa. +const OwnershipOriginHeader = "P2p-Forge-Origin" + +// Ownership proof validity bounds. The signer picks a window up to +// MaxOwnershipWindow; the verifier enforces the cap. 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 control of the endpoint. +const ( + DefaultOwnershipWindow = 24 * time.Hour + MaxOwnershipWindow = 14 * 24 * time.Hour + ownershipClockSkew = 5 * time.Minute +) + +// ownershipComponents is the fixed covered set for an ownership proof. +var ownershipComponents = []string{"p2p-forge-origin", "content-digest"} + +// SignOwnership builds the headers a node serves at +// /.well-known/p2p-forge/ to prove its key controls origin. The proof +// is static (no per-request input), so it can be signed offline and cached: the +// identity key never has to touch the HTTP tier. +func SignOwnership(priv crypto.PrivKey, origin string, created, expires int64) (http.Header, error) { + keyID, err := EncodeDIDKey(priv.GetPublic()) + if err != nil { + return nil, err + } + cd, err := ContentDigest(nil) // empty body + if err != nil { + return nil, err + } + + il := buildOwnershipInnerList(sigMeta{created: created, expires: expires, keyID: keyID, tag: OwnershipTag}) + sigParams, err := httpsfv.Marshal(il) + if err != nil { + return nil, fmt.Errorf("marshal ownership signature params: %w", err) + } + comps := []componentValue{ + {id: "p2p-forge-origin", value: origin}, + {id: "content-digest", value: cd}, + } + sig, err := priv.Sign([]byte(signatureBase(comps, sigParams))) + if err != nil { + return nil, fmt.Errorf("signing ownership proof: %w", err) + } + + h := http.Header{} + h.Set(OwnershipOriginHeader, origin) + h.Set(contentDigestHeader, cd) + + inputDict := httpsfv.NewDictionary() + inputDict.Add(sigLabel, il) + inputStr, err := httpsfv.Marshal(inputDict) + if err != nil { + return nil, fmt.Errorf("marshal ownership Signature-Input: %w", err) + } + h.Set("Signature-Input", inputStr) + + sigDict := httpsfv.NewDictionary() + sigDict.Add(sigLabel, httpsfv.NewItem(sig)) + sigStr, err := httpsfv.Marshal(sigDict) + if err != nil { + return nil, fmt.Errorf("marshal ownership Signature: %w", err) + } + h.Set("Signature", sigStr) + return h, nil +} + +// OwnershipVerifyConfig carries the inputs for verifying a fetched proof. +type OwnershipVerifyConfig struct { + // KeyID is the registration did:key. The proof MUST verify under this key + // and name it, never a key the proof itself supplies. + KeyID string + // ExpectedOrigin is the canonical origin the forge connected to. + ExpectedOrigin string + Now time.Time +} + +// VerifyOwnership checks a fetched ownership proof: the Content-Digest matches +// the body, the covered set and tag are exact, the named key is the +// registration key, the origin matches what the forge connected to, the window +// is current and bounded, and the signature verifies under the registration key. +func VerifyOwnership(header http.Header, body []byte, cfg OwnershipVerifyConfig) error { + if cfg.KeyID == "" || cfg.ExpectedOrigin == "" { + return errors.New("ownership verify misconfigured: empty keyid or origin") + } + + cds := header.Values(contentDigestHeader) + if len(cds) != 1 { + return fmt.Errorf("expected exactly one Content-Digest, got %d", len(cds)) + } + if err := verifyContentDigest(cds[0], body); err != nil { + return err + } + + inputs := header.Values("Signature-Input") + if len(inputs) != 1 { + return fmt.Errorf("expected exactly one Signature-Input, got %d", len(inputs)) + } + sigs := header.Values("Signature") + if len(sigs) != 1 { + return fmt.Errorf("expected exactly one Signature, got %d", len(sigs)) + } + inputDict, err := httpsfv.UnmarshalDictionary([]string{inputs[0]}) + if err != nil { + return fmt.Errorf("parse ownership Signature-Input: %w", err) + } + sigDict, err := httpsfv.UnmarshalDictionary([]string{sigs[0]}) + if err != nil { + return fmt.Errorf("parse ownership Signature: %w", err) + } + + label, il, err := selectByTag(inputDict, OwnershipTag) + if err != nil { + return err + } + if err := checkComponents(il, ownershipComponents); err != nil { + return err + } + created, err := intParam(il.Params, "created") + if err != nil { + return err + } + expires, err := intParam(il.Params, "expires") + if err != nil { + return err + } + keyID, err := strParam(il.Params, "keyid") + if err != nil { + return err + } + if keyID != cfg.KeyID { + return fmt.Errorf("ownership proof keyid does not match the registration key") + } + if err := checkOwnershipWindow(created, expires, cfg.Now); err != nil { + return err + } + + origin := header.Get(OwnershipOriginHeader) + if origin != cfg.ExpectedOrigin { + return fmt.Errorf("ownership proof origin %q does not match %q", origin, cfg.ExpectedOrigin) + } + + pub, err := DecodeDIDKey(cfg.KeyID) + if err != nil { + return err + } + sig, err := signatureBytes(sigDict, label) + if err != nil { + return err + } + sigParams, err := httpsfv.Marshal(il) + if err != nil { + return fmt.Errorf("re-marshal ownership signature params: %w", err) + } + comps := []componentValue{ + {id: "p2p-forge-origin", value: origin}, + {id: "content-digest", value: cds[0]}, + } + ok, err := pub.Verify([]byte(signatureBase(comps, sigParams)), sig) + if err != nil { + return fmt.Errorf("verifying ownership proof: %w", err) + } + if !ok { + return errors.New("ownership proof signature verification failed") + } + return nil +} + +// buildOwnershipInnerList builds the proof inner list: covered components plus +// created/expires/keyid/tag (no nonce, since the proof is static). +func buildOwnershipInnerList(m sigMeta) httpsfv.InnerList { + items := make([]httpsfv.Item, len(ownershipComponents)) + for i, id := range ownershipComponents { + items[i] = httpsfv.NewItem(id) + } + params := httpsfv.NewParams() + params.Add("created", m.created) + params.Add("expires", m.expires) + params.Add("keyid", m.keyID) + params.Add("tag", m.tag) + return httpsfv.InnerList{Items: items, Params: params} +} + +func checkOwnershipWindow(created, expires int64, now time.Time) error { + if created == 0 || expires == 0 { + return errors.New("ownership proof missing created/expires") + } + if expires <= created { + return errors.New("ownership proof expires before created") + } + if expires-created > int64(MaxOwnershipWindow/time.Second) { + return fmt.Errorf("ownership proof window exceeds %s", MaxOwnershipWindow) + } + ct, et := time.Unix(created, 0), time.Unix(expires, 0) + if ct.After(now.Add(ownershipClockSkew)) { + return errors.New("ownership proof not yet valid") + } + if et.Before(now.Add(-ownershipClockSkew)) { + return errors.New("ownership proof has expired") + } + return nil +} diff --git a/internal/httpsig/ownership_test.go b/internal/httpsig/ownership_test.go new file mode 100644 index 0000000..919f95e --- /dev/null +++ b/internal/httpsig/ownership_test.go @@ -0,0 +1,87 @@ +package httpsig + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOwnershipRoundTrip(t *testing.T) { + priv := fixedKey(t, 0x11) + keyID, err := EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + now := time.Unix(1_700_000_000, 0) + origin := "https://gateway.example:8443" + + hdr, err := SignOwnership(priv, origin, now.Unix(), now.Add(DefaultOwnershipWindow).Unix()) + require.NoError(t, err) + + err = VerifyOwnership(hdr, nil, OwnershipVerifyConfig{ + KeyID: keyID, + ExpectedOrigin: origin, + Now: now, + }) + require.NoError(t, err) +} + +func TestOwnershipRejects(t *testing.T) { + priv := fixedKey(t, 0x12) + other := fixedKey(t, 0x13) + keyID, err := EncodeDIDKey(priv.GetPublic()) + require.NoError(t, err) + otherID, err := EncodeDIDKey(other.GetPublic()) + require.NoError(t, err) + now := time.Unix(1_700_000_000, 0) + origin := "https://gateway.example" + + base, err := SignOwnership(priv, origin, now.Unix(), now.Add(time.Hour).Unix()) + require.NoError(t, err) + + t.Run("origin mismatch", func(t *testing.T) { + err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: "https://evil.example", Now: now}) + require.ErrorContains(t, err, "origin") + }) + + t.Run("scheme mismatch (http vs https)", func(t *testing.T) { + err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: "http://gateway.example", Now: now}) + require.ErrorContains(t, err, "origin") + }) + + t.Run("verified under wrong registration key", func(t *testing.T) { + // The proof is signed by priv but named keyID; verifying against a + // different registration key must fail the keyid cross-check. + err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: otherID, ExpectedOrigin: origin, Now: now}) + require.ErrorContains(t, err, "keyid") + }) + + t.Run("proof signed by a different key than it names", func(t *testing.T) { + // Craft a proof for origin signed by `other` but the forge expects it + // under `other`'s did:key; a proof that names other but is served for a + // registration under keyID would be caught by the keyid check above. + // Here we confirm a genuine other-signed proof still verifies under its + // own key, so the security rests on binding keyid to the registration. + otherProof, err := SignOwnership(other, origin, now.Unix(), now.Add(time.Hour).Unix()) + require.NoError(t, err) + require.NoError(t, VerifyOwnership(otherProof, nil, OwnershipVerifyConfig{KeyID: otherID, ExpectedOrigin: origin, Now: now})) + // ...but not under keyID. + require.Error(t, VerifyOwnership(otherProof, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now})) + }) + + t.Run("expired", func(t *testing.T) { + err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now.Add(2 * time.Hour)}) + require.ErrorContains(t, err, "expired") + }) + + t.Run("window too long", func(t *testing.T) { + tooLong, err := SignOwnership(priv, origin, now.Unix(), now.Add(MaxOwnershipWindow+time.Hour).Unix()) + require.NoError(t, err) + err = VerifyOwnership(tooLong, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now}) + require.ErrorContains(t, err, "window") + }) + + t.Run("body tampered", func(t *testing.T) { + err := VerifyOwnership(base, []byte("unexpected body"), OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now}) + require.ErrorContains(t, err, "content-digest") + }) +} From 0d2a922841e32e0f335ee9bf605912a0d7bb7fcc Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 13:35:07 +0200 Subject: [PATCH 05/27] docs: document the /v2 registration API Add docs/registration-v2.md as the normative reference for the RFC 9421 /v2 API, using RFC 2119 requirements language: endpoints, the signing profile with worked signature bases, the http-ownership proof, error shapes, and operator config. http-ownership is the SHOULD path; libp2p-dialback is OPTIONAL, so a forge can drop the libp2p stack. Add docs/key-types.md as the companion on adding key types (the post-quantum ML-DSA standards path, and why a large public key stays out of the DNS label). Point the README at the spec, and at the libp2p AutoTLS spec for /v1, instead of restating the v1 request inline. Record the new options and the X-Forwarded-For fix in the changelog. --- CHANGELOG.md | 6 + README.md | 40 +++--- docs/key-types.md | 138 ++++++++++++++++++++ docs/registration-v2.md | 273 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 434 insertions(+), 23 deletions(-) create mode 100644 docs/key-types.md create mode 100644 docs/registration-v2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 540ca90..63bfdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +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` block options: `allow-private-addresses` (turns off destination-IP vetting for local testing or trusted private deployments, default false) and `client-ip-header` (names the trusted proxy header for the real client IP, e.g. `CF-Connecting-IP`). ### 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. ### Fixed diff --git a/README.md b/README.md index 6edc8de..08599de 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 destination-IP vetting, the address cap, and the dial timeout on the reachability probe. 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 rate limiting and denylist. Without it, only the direct connection address is trusted; a forged `X-Forwarded-For` is ignored. - **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/docs/key-types.md b/docs/key-types.md new file mode 100644 index 0000000..e33ebf6 --- /dev/null +++ b/docs/key-types.md @@ -0,0 +1,138 @@ +# 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. Adding a type means accepting its multicodec when decoding the + `did:key`. Old clients are unaffected, and the `GET /v2` descriptor advertises + which key types an instance accepts, so a client can discover support. +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 + +libp2p keys map to multicodecs and RFC 9421 algorithms as follows. Only the +first is enabled today. + +| Key | Multicodec | RFC 9421 algorithm | Notes | +| --- | --- | --- | --- | +| Ed25519 | `0xed` | `ed25519` | Enabled. libp2p signs raw 64-byte Ed25519, which is exactly what RFC 9421 `ed25519` expects, so it interoperates with off-the-shelf tooling. This is why it is the primary. | +| ECDSA P-256 | `0x1200` | `ecdsa-p256-sha256` | Registered, but an encoding gap: libp2p signs ASN.1 DER, while RFC 9421 (section 3.3) mandates the fixed-width `r \|\| s` form (as in JWS). An implementation MUST convert between them. | +| ECDSA P-384 | `0x1201` | `ecdsa-p384-sha384` | Same encoding gap as P-256. | +| RSA (PKCS#1 v1.5, SHA-256) | `0x1205` | `rsa-v1_5-sha256` | Registered and encoding-compatible. RSA signatures and keys are large, so mind the request size limits. | +| secp256k1 | `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). Treat as a last resort until a registration exists. | + +## 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 IANA HTTP Message Signature Algorithms registry already lists `ml-dsa-44`, + `ml-dsa-65`, and `ml-dsa-87` as Active, defined by the C2SP + [`httpsig-pq`](https://c2sp.org/httpsig-pq) specification. + +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 | Multicodec | RFC 9421 algorithm | Status | +| --- | --- | --- | --- | +| ML-DSA-44 / 65 / 87 | `0x1210` / `0x1211` / `0x1212` (draft) | `ml-dsa-44` / `ml-dsa-65` / `ml-dsa-87` (Active) | Identifiers exist on both layers; blocked on libp2p key support. | +| SLH-DSA (all parameter sets) | `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 the DER-to-`r \|\| s` conversion before enabling the type, + and add a test vector, so non-Go and non-libp2p signers can interoperate. +- Do not invent an `alg` identifier when a registered one fits. Adopt the + published multicodec and IANA RFC 9421 identifiers (for example the `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..1085b1b --- /dev/null +++ b/docs/registration-v2.md @@ -0,0 +1,273 @@ +# 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`. | +| `GET` | `/v2` | Static JSON descriptor of this profile (accepted keys, covered components, limits). | + +## 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` | Random, at least 128 bits, base64url. MUST be single-use (see replay below). | +| `keyid` | The `did:key` above. | +| `tag` | `p2p-forge-reg`. | + +`@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 or an +`expires` more than 2 minutes in the past (clock skew). 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="dGVzdG5vbmNlMTIzNA";keyid="did:key:z6Mk...";tag="p2p-forge-reg" +``` + +The `Signature-Input` and `Signature` headers use the label `sig1`: + +``` +Signature-Input: sig1=("@method" "@authority" "@path" "content-type" "content-digest");created=1700000000;expires=1700000060;nonce="dGVzdG5vbmNlMTIzNA";keyid="did:key:z6Mk...";tag="p2p-forge-reg" +Signature: sig1=:: +``` + +`Signature-Input` MUST be in RFC 8941 canonical form. The signature is a raw +Ed25519 signature over the UTF-8 bytes of the base. + +### Body + +```json +{ + "value": "", + "addresses": ["", "..."] +} +``` + +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: + +```json +{ + "did": "did:key:z6Mk...", + "name": "*..libp2p.direct", + "verification": {"mode": "http-ownership"}, + "ttl": 3600 +} +``` + +## 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. + +### http-ownership (no libp2p) + +The node MUST serve a static proof at: + +``` +GET https://[:]/.well-known/p2p-forge/ +``` + +The response MUST have an empty body and carry these headers, which the node +signs once and reuses until close to expiry (so the proof is cacheable and can be +signed offline): + +``` +P2p-Forge-Origin: https://: +Content-Digest: sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=: +Signature-Input: sig1=("p2p-forge-origin" "content-digest");created=...;expires=...;keyid="did:key:z6Mk...";tag="p2p-forge-ownership" +Signature: sig1=:: +``` + +The signature base is: + +``` +"p2p-forge-origin": https://: +"content-digest": sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=: +"@signature-params": ("p2p-forge-origin" "content-digest");created=...;expires=...;keyid="did:key:z6Mk...";tag="p2p-forge-ownership" +``` + +The forge MUST verify the proof under the registration `keyid`, and MUST NOT use +any key the proof itself names. It MUST check that `P2p-Forge-Origin` equals the +origin it connected to (scheme, host, and port are all bound, so an `http` proof +cannot satisfy an `https` address), and MUST check the window is current. The +window MUST NOT exceed 14 days. 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 four headers. +- The endpoint MUST answer on port 80 or 443 on the public forge instance. +- The forge MUST pin the connection to the endpoint's resolved public IP, MUST + refuse a non-public target, and MUST NOT follow redirects. It verifies TLS + against a real CA cert when one is present, and otherwise falls back to + trusting the signature and the IP (a node registering because it has no cert + yet is the common case). + +### 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) +with a distinct `type` per class, and MUST use a status consistent with the table +below. + +| Status | When | +| --- | --- | +| `400` | Malformed body, digest, signature, address, or a query string. | +| `401` | Signature invalid, or outside the clock window. | +| `403` | Denylisted, or missing `Forge-Authorization` when required. | +| `409` | Nonce already used (replay). | +| `413` | Body too large. | +| `422` | No submitted address could be verified. | +| `429` | Rate limited. Carries `Retry-After`. | + +## Anti-abuse + +- **Rate limiting** SHOULD run per source IP, before the signature is verified. + The forge MUST NOT trust a leftmost `X-Forwarded-For`, 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`). +- **Replay.** The server MUST treat each `nonce` as single-use and reject a + reused one, so a captured request cannot be replayed within its window. +- **Denylist** applies to the client IP and to every resolved endpoint IP. + +## Operator configuration + +Two `acme` block options affect `/v2` (see the README for full syntax): + +- `allow-private-addresses true` turns off destination-IP vetting, the address + cap, and the dial timeout. 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). Without it, + only the direct connection address is trusted for rate limiting and denylist. + +## 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. From 4eea510a88e9bb7b955aa4e76a1f531def7df577 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 22:56:55 +0200 Subject: [PATCH 06/27] refactor(acme): drop identify-event agent-version lookup The probe subscribed to the libp2p identify event only to label a bounded-cardinality metric more accurately. Drop the subscription and the helper; label the probe from the HTTP User-Agent, which the handler already passes in. No security or behavior change beyond the metric label. --- acme/dialguard.go | 46 ++-------------------------------------------- 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/acme/dialguard.go b/acme/dialguard.go index a17d0f6..32248a4 100644 --- a/acme/dialguard.go +++ b/acme/dialguard.go @@ -9,7 +9,6 @@ import ( "github.com/ipshipyard/p2p-forge/denylist" "github.com/libp2p/go-libp2p" "github.com/libp2p/go-libp2p/core/control" - "github.com/libp2p/go-libp2p/core/event" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multiaddr" @@ -21,10 +20,6 @@ import ( // target cannot pin a request goroutine and its libp2p host. const probeTimeout = 15 * time.Second -// identifyWait bounds how long we wait for the identify exchange to report the -// peer's agent version (a metric label only). -const identifyWait = 3 * time.Second - // maxProbeAddresses bounds how many addresses one registration may ask the // forge to dial, limiting dial fan-out and reflection. It is enforced only when // address vetting is on (see acmeWriter.AllowPrivateAddrs). @@ -242,13 +237,6 @@ func (c *acmeWriter) testAddresses(ctx context.Context, p peer.ID, addrStrs []st } defer h.Close() - // Subscribe before connecting so identify completion is observed - // deterministically instead of racing a peerstore read. - sub, subErr := h.EventBus().Subscribe(new(event.EvtPeerIdentificationCompleted)) - if subErr == nil { - defer sub.Close() - } - if err := h.Connect(dialCtx, peer.AddrInfo{ID: p, Addrs: dialable}); err != nil { recordPeerProbe("error", agentVersion) // Return a generic error: the underlying dial result (refused vs @@ -258,37 +246,7 @@ func (c *acmeWriter) testAddresses(ctx context.Context, p peer.ID, addrStrs []st return fmt.Errorf("peer is not reachable at any submitted address") } - agentVersion = identifiedAgent(dialCtx, sub, p, agentVersion) - log.Debugf("connected to peer %s - UserAgent: %q", p, agentVersion) - recordPeerProbe("ok", agentType(agentVersion)) + log.Debugf("connected to peer %s (agent %q)", p, agentVersion) + recordPeerProbe("ok", agentVersion) return nil } - -// identifiedAgent waits briefly for the identify exchange with p to complete and -// returns its reported agent version, falling back to the passed value. -func identifiedAgent(ctx context.Context, sub event.Subscription, p peer.ID, fallback string) string { - if sub == nil { - return fallback - } - timer := time.NewTimer(identifyWait) - defer timer.Stop() - for { - select { - case <-ctx.Done(): - return fallback - case <-timer.C: - return fallback - case e, ok := <-sub.Out(): - if !ok { - return fallback - } - ev, ok := e.(event.EvtPeerIdentificationCompleted) - if ok && ev.Peer == p { - if ev.AgentVersion != "" { - return ev.AgentVersion - } - return fallback - } - } - } -} From 32bc2f45c50c7368b518e7e7018c05403848d997 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 23:03:03 +0200 Subject: [PATCH 07/27] refactor: prove endpoint ownership with a JWT Replace the bespoke RFC 9421 response-signature ownership proof with a standard EdDSA JWT (golang-jwt/jwt/v5). The node serves a compact token with an `origin` claim plus `iat`/`exp` at the well-known path; the forge verifies it under the registration key (never the token's own `kid`) and checks the origin and expiry. This drops internal/httpsig/ownership.go for a ubiquitous standard that fits the did:key ecosystem, and keeps the SSRF-safe fetch unchanged. --- acme/ownership.go | 45 +++--- client/ownership.go | 49 +++--- docs/registration-v2.md | 44 +++--- go.mod | 2 +- go.sum | 4 +- internal/httpsig/ownership.go | 215 --------------------------- internal/httpsig/ownership_test.go | 87 ----------- internal/ownership/ownership.go | 79 ++++++++++ internal/ownership/ownership_test.go | 52 +++++++ 9 files changed, 206 insertions(+), 371 deletions(-) delete mode 100644 internal/httpsig/ownership.go delete mode 100644 internal/httpsig/ownership_test.go create mode 100644 internal/ownership/ownership.go create mode 100644 internal/ownership/ownership_test.go diff --git a/acme/ownership.go b/acme/ownership.go index f8a2831..c7018c0 100644 --- a/acme/ownership.go +++ b/acme/ownership.go @@ -2,6 +2,7 @@ package acme import ( "context" + "crypto/ed25519" "crypto/tls" "errors" "fmt" @@ -15,14 +16,15 @@ import ( "github.com/ipshipyard/p2p-forge/client" "github.com/ipshipyard/p2p-forge/denylist" "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/ipshipyard/p2p-forge/internal/ownership" "github.com/libp2p/go-libp2p/core/peer" ) const ( // ownershipFetchTimeout bounds one proof fetch. ownershipFetchTimeout = 15 * time.Second - // ownershipBodyLimit caps the proof response body (the body is empty). - ownershipBodyLimit = 4 << 10 + // 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, @@ -116,15 +118,22 @@ func (c *acmeWriter) fetchAndVerifyOwnership(ctx context.Context, keyID string, } } - hdr, body, err := fetchOwnershipProof(ctx, o, ip, keyID) + // The proof must verify under the registration key, never a key the proof + // itself names. + regPub, err := httpsig.DecodeDIDKey(keyID) if err != nil { return err } - return httpsig.VerifyOwnership(hdr, body, httpsig.OwnershipVerifyConfig{ - KeyID: keyID, - ExpectedOrigin: o.Origin, - Now: time.Now(), - }) + raw, err := regPub.Raw() + if err != nil { + return fmt.Errorf("reading registration key: %w", err) + } + + proof, err := fetchOwnershipProof(ctx, o, ip, keyID) + if err != nil { + return err + } + return ownership.Verify(string(proof), ed25519.PublicKey(raw), o.Origin, time.Now()) } // resolvePinnedIP resolves host to a single vettable public IP to pin the fetch @@ -159,11 +168,11 @@ func (c *acmeWriter) resolvePinnedIP(ctx context.Context, host string) (netip.Ad // For https it first tries WebPKI verification (strong host binding); if that // fails (e.g. the node has no valid cert yet) it retries without verification, // where host binding rests on the IP pin and key binding on the signature. -func fetchOwnershipProof(ctx context.Context, o client.HTTPOrigin, ip netip.Addr, keyID string) (http.Header, []byte, error) { +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) - do := func(insecure bool) (http.Header, []byte, error) { + do := func(insecure bool) ([]byte, error) { transport := &http.Transport{ DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { var d net.Dialer @@ -182,27 +191,27 @@ func fetchOwnershipProof(ctx context.Context, o client.HTTPOrigin, ip netip.Addr } req, err := http.NewRequestWithContext(ctx, http.MethodGet, proofURL, nil) if err != nil { - return nil, nil, err + return nil, err } resp, err := httpClient.Do(req) if err != nil { - return nil, nil, err + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, nil, fmt.Errorf("ownership endpoint returned %s", resp.Status) + return nil, fmt.Errorf("ownership endpoint returned %s", resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, ownershipBodyLimit)) if err != nil { - return nil, nil, fmt.Errorf("reading ownership proof: %w", err) + return nil, fmt.Errorf("reading ownership proof: %w", err) } - return resp.Header, body, nil + return body, nil } if o.Scheme == "https" { - hdr, body, err := do(false) + body, err := do(false) if err == nil { - return hdr, body, nil + return body, nil } // Retry without verification only when the cert itself failed to verify // (the node has no valid CA cert yet). Do not retry on a refused @@ -211,7 +220,7 @@ func fetchOwnershipProof(ctx context.Context, o client.HTTPOrigin, ip netip.Addr if errors.As(err, &certErr) { return do(true) } - return nil, nil, err + return nil, err } return do(true) } diff --git a/client/ownership.go b/client/ownership.go index 7e4ce51..c654820 100644 --- a/client/ownership.go +++ b/client/ownership.go @@ -1,7 +1,9 @@ package client import ( + "crypto/ed25519" "fmt" + "io" "net/http" "net/url" "strings" @@ -9,6 +11,7 @@ import ( "time" "github.com/ipshipyard/p2p-forge/internal/httpsig" + "github.com/ipshipyard/p2p-forge/internal/ownership" "github.com/libp2p/go-libp2p/core/crypto" ) @@ -67,9 +70,9 @@ func CanonicalOrigin(rawURL string) (HTTPOrigin, error) { } // OwnershipProofHandler returns an http.Handler that serves the node's ownership -// proof at WellKnownProofPath+ for the given origin. Mount it on the -// node's existing HTTP server. The signed proof is cached and refreshed before -// it expires, so it stays cacheable and the signing key is used rarely. +// 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) { @@ -77,11 +80,15 @@ func OwnershipProofHandler(privKey crypto.PrivKey, rawURL string) (http.Handler, if err != nil { return nil, err } - did, err := httpsig.EncodeDIDKey(privKey.GetPublic()) + did, err := httpsig.EncodeDIDKey(privKey.GetPublic()) // also rejects non-Ed25519 keys if err != nil { return nil, err } - signer := &ownershipSigner{priv: privKey, origin: o.Origin, window: httpsig.DefaultOwnershipWindow} + 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) { @@ -94,45 +101,41 @@ func OwnershipProofHandler(privKey crypto.PrivKey, rawURL string) (http.Handler, http.NotFound(w, r) return } - hdr, err := signer.headers(time.Now()) + proof, err := signer.token(time.Now()) if err != nil { http.Error(w, "could not produce ownership proof", http.StatusInternalServerError) return } - for k, vs := range hdr { - for _, v := range vs { - w.Header().Add(k, v) - } + w.Header().Set("Content-Type", "application/jwt") + if r.Method == http.MethodHead { + return } - w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, proof) }), nil } -// ownershipSigner caches a signed proof and re-signs it before expiry. +// ownershipSigner caches a signed proof JWT and re-signs it before expiry. type ownershipSigner struct { - priv crypto.PrivKey + priv ed25519.PrivateKey origin string - window time.Duration mu sync.Mutex - cached http.Header + cached string expires time.Time } -func (s *ownershipSigner) headers(now time.Time) (http.Header, error) { +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 == nil || now.After(s.expires.Add(-s.window/4)) { - created := now.Add(-1 * time.Minute) // small backdate for verifier clock skew - expires := now.Add(s.window) - h, err := httpsig.SignOwnership(s.priv, s.origin, created.Unix(), expires.Unix()) + 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 nil, err + return "", err } - s.cached = h - s.expires = expires + s.cached = tok + s.expires = now.Add(ownership.DefaultWindow) } return s.cached, nil } diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 1085b1b..c46855b 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -166,37 +166,31 @@ fails. ### http-ownership (no libp2p) -The node MUST serve a static proof at: +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 https://[:]/.well-known/p2p-forge/ ``` -The response MUST have an empty body and carry these headers, which the node -signs once and reuses until close to expiry (so the proof is cacheable and can be -signed offline): +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 use +`alg: EdDSA`, and the payload carries these claims: -``` -P2p-Forge-Origin: https://: -Content-Digest: sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=: -Signature-Input: sig1=("p2p-forge-origin" "content-digest");created=...;expires=...;keyid="did:key:z6Mk...";tag="p2p-forge-ownership" -Signature: sig1=:: -``` - -The signature base is: - -``` -"p2p-forge-origin": https://: -"content-digest": sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=: -"@signature-params": ("p2p-forge-origin" "content-digest");created=...;expires=...;keyid="did:key:z6Mk...";tag="p2p-forge-ownership" -``` +| Claim | Meaning | +| --- | --- | +| `origin` | The canonical `scheme://host:port` the key controls. | +| `iat` | Issued-at, unix seconds. | +| `exp` | Expiry, unix seconds. `exp - iat` MUST NOT exceed 14 days. | -The forge MUST verify the proof under the registration `keyid`, and MUST NOT use -any key the proof itself names. It MUST check that `P2p-Forge-Origin` equals the -origin it connected to (scheme, host, and port are all bound, so an `http` proof -cannot satisfy an `https` address), and MUST check the window is current. The -window MUST NOT exceed 14 days. Freshness comes from the forge fetching the proof -live, so a stale proof at a dead node does not verify. +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 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. 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 @@ -206,7 +200,7 @@ 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 four headers. + the HTTP tier. A static file server or a CDN can serve the token. - The endpoint MUST answer on port 80 or 443 on the public forge instance. - The forge MUST pin the connection to the endpoint's resolved public IP, MUST refuse a non-public target, and MUST NOT follow redirects. It verifies TLS diff --git a/go.mod b/go.mod index 9fa04b0..40c7a3e 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( 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 @@ -68,7 +69,6 @@ require ( 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/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 diff --git a/go.sum b/go.sum index c181b6b..c37eadc 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ 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/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= diff --git a/internal/httpsig/ownership.go b/internal/httpsig/ownership.go deleted file mode 100644 index 4493928..0000000 --- a/internal/httpsig/ownership.go +++ /dev/null @@ -1,215 +0,0 @@ -package httpsig - -import ( - "errors" - "fmt" - "net/http" - "time" - - "github.com/dunglas/httpsfv" - "github.com/libp2p/go-libp2p/core/crypto" -) - -// OwnershipOriginHeader carries the canonical origin (scheme://host[:port]) the -// proof is bound to. Binding the scheme means an http proof cannot satisfy an -// https address, and vice versa. -const OwnershipOriginHeader = "P2p-Forge-Origin" - -// Ownership proof validity bounds. The signer picks a window up to -// MaxOwnershipWindow; the verifier enforces the cap. 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 control of the endpoint. -const ( - DefaultOwnershipWindow = 24 * time.Hour - MaxOwnershipWindow = 14 * 24 * time.Hour - ownershipClockSkew = 5 * time.Minute -) - -// ownershipComponents is the fixed covered set for an ownership proof. -var ownershipComponents = []string{"p2p-forge-origin", "content-digest"} - -// SignOwnership builds the headers a node serves at -// /.well-known/p2p-forge/ to prove its key controls origin. The proof -// is static (no per-request input), so it can be signed offline and cached: the -// identity key never has to touch the HTTP tier. -func SignOwnership(priv crypto.PrivKey, origin string, created, expires int64) (http.Header, error) { - keyID, err := EncodeDIDKey(priv.GetPublic()) - if err != nil { - return nil, err - } - cd, err := ContentDigest(nil) // empty body - if err != nil { - return nil, err - } - - il := buildOwnershipInnerList(sigMeta{created: created, expires: expires, keyID: keyID, tag: OwnershipTag}) - sigParams, err := httpsfv.Marshal(il) - if err != nil { - return nil, fmt.Errorf("marshal ownership signature params: %w", err) - } - comps := []componentValue{ - {id: "p2p-forge-origin", value: origin}, - {id: "content-digest", value: cd}, - } - sig, err := priv.Sign([]byte(signatureBase(comps, sigParams))) - if err != nil { - return nil, fmt.Errorf("signing ownership proof: %w", err) - } - - h := http.Header{} - h.Set(OwnershipOriginHeader, origin) - h.Set(contentDigestHeader, cd) - - inputDict := httpsfv.NewDictionary() - inputDict.Add(sigLabel, il) - inputStr, err := httpsfv.Marshal(inputDict) - if err != nil { - return nil, fmt.Errorf("marshal ownership Signature-Input: %w", err) - } - h.Set("Signature-Input", inputStr) - - sigDict := httpsfv.NewDictionary() - sigDict.Add(sigLabel, httpsfv.NewItem(sig)) - sigStr, err := httpsfv.Marshal(sigDict) - if err != nil { - return nil, fmt.Errorf("marshal ownership Signature: %w", err) - } - h.Set("Signature", sigStr) - return h, nil -} - -// OwnershipVerifyConfig carries the inputs for verifying a fetched proof. -type OwnershipVerifyConfig struct { - // KeyID is the registration did:key. The proof MUST verify under this key - // and name it, never a key the proof itself supplies. - KeyID string - // ExpectedOrigin is the canonical origin the forge connected to. - ExpectedOrigin string - Now time.Time -} - -// VerifyOwnership checks a fetched ownership proof: the Content-Digest matches -// the body, the covered set and tag are exact, the named key is the -// registration key, the origin matches what the forge connected to, the window -// is current and bounded, and the signature verifies under the registration key. -func VerifyOwnership(header http.Header, body []byte, cfg OwnershipVerifyConfig) error { - if cfg.KeyID == "" || cfg.ExpectedOrigin == "" { - return errors.New("ownership verify misconfigured: empty keyid or origin") - } - - cds := header.Values(contentDigestHeader) - if len(cds) != 1 { - return fmt.Errorf("expected exactly one Content-Digest, got %d", len(cds)) - } - if err := verifyContentDigest(cds[0], body); err != nil { - return err - } - - inputs := header.Values("Signature-Input") - if len(inputs) != 1 { - return fmt.Errorf("expected exactly one Signature-Input, got %d", len(inputs)) - } - sigs := header.Values("Signature") - if len(sigs) != 1 { - return fmt.Errorf("expected exactly one Signature, got %d", len(sigs)) - } - inputDict, err := httpsfv.UnmarshalDictionary([]string{inputs[0]}) - if err != nil { - return fmt.Errorf("parse ownership Signature-Input: %w", err) - } - sigDict, err := httpsfv.UnmarshalDictionary([]string{sigs[0]}) - if err != nil { - return fmt.Errorf("parse ownership Signature: %w", err) - } - - label, il, err := selectByTag(inputDict, OwnershipTag) - if err != nil { - return err - } - if err := checkComponents(il, ownershipComponents); err != nil { - return err - } - created, err := intParam(il.Params, "created") - if err != nil { - return err - } - expires, err := intParam(il.Params, "expires") - if err != nil { - return err - } - keyID, err := strParam(il.Params, "keyid") - if err != nil { - return err - } - if keyID != cfg.KeyID { - return fmt.Errorf("ownership proof keyid does not match the registration key") - } - if err := checkOwnershipWindow(created, expires, cfg.Now); err != nil { - return err - } - - origin := header.Get(OwnershipOriginHeader) - if origin != cfg.ExpectedOrigin { - return fmt.Errorf("ownership proof origin %q does not match %q", origin, cfg.ExpectedOrigin) - } - - pub, err := DecodeDIDKey(cfg.KeyID) - if err != nil { - return err - } - sig, err := signatureBytes(sigDict, label) - if err != nil { - return err - } - sigParams, err := httpsfv.Marshal(il) - if err != nil { - return fmt.Errorf("re-marshal ownership signature params: %w", err) - } - comps := []componentValue{ - {id: "p2p-forge-origin", value: origin}, - {id: "content-digest", value: cds[0]}, - } - ok, err := pub.Verify([]byte(signatureBase(comps, sigParams)), sig) - if err != nil { - return fmt.Errorf("verifying ownership proof: %w", err) - } - if !ok { - return errors.New("ownership proof signature verification failed") - } - return nil -} - -// buildOwnershipInnerList builds the proof inner list: covered components plus -// created/expires/keyid/tag (no nonce, since the proof is static). -func buildOwnershipInnerList(m sigMeta) httpsfv.InnerList { - items := make([]httpsfv.Item, len(ownershipComponents)) - for i, id := range ownershipComponents { - items[i] = httpsfv.NewItem(id) - } - params := httpsfv.NewParams() - params.Add("created", m.created) - params.Add("expires", m.expires) - params.Add("keyid", m.keyID) - params.Add("tag", m.tag) - return httpsfv.InnerList{Items: items, Params: params} -} - -func checkOwnershipWindow(created, expires int64, now time.Time) error { - if created == 0 || expires == 0 { - return errors.New("ownership proof missing created/expires") - } - if expires <= created { - return errors.New("ownership proof expires before created") - } - if expires-created > int64(MaxOwnershipWindow/time.Second) { - return fmt.Errorf("ownership proof window exceeds %s", MaxOwnershipWindow) - } - ct, et := time.Unix(created, 0), time.Unix(expires, 0) - if ct.After(now.Add(ownershipClockSkew)) { - return errors.New("ownership proof not yet valid") - } - if et.Before(now.Add(-ownershipClockSkew)) { - return errors.New("ownership proof has expired") - } - return nil -} diff --git a/internal/httpsig/ownership_test.go b/internal/httpsig/ownership_test.go deleted file mode 100644 index 919f95e..0000000 --- a/internal/httpsig/ownership_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package httpsig - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestOwnershipRoundTrip(t *testing.T) { - priv := fixedKey(t, 0x11) - keyID, err := EncodeDIDKey(priv.GetPublic()) - require.NoError(t, err) - now := time.Unix(1_700_000_000, 0) - origin := "https://gateway.example:8443" - - hdr, err := SignOwnership(priv, origin, now.Unix(), now.Add(DefaultOwnershipWindow).Unix()) - require.NoError(t, err) - - err = VerifyOwnership(hdr, nil, OwnershipVerifyConfig{ - KeyID: keyID, - ExpectedOrigin: origin, - Now: now, - }) - require.NoError(t, err) -} - -func TestOwnershipRejects(t *testing.T) { - priv := fixedKey(t, 0x12) - other := fixedKey(t, 0x13) - keyID, err := EncodeDIDKey(priv.GetPublic()) - require.NoError(t, err) - otherID, err := EncodeDIDKey(other.GetPublic()) - require.NoError(t, err) - now := time.Unix(1_700_000_000, 0) - origin := "https://gateway.example" - - base, err := SignOwnership(priv, origin, now.Unix(), now.Add(time.Hour).Unix()) - require.NoError(t, err) - - t.Run("origin mismatch", func(t *testing.T) { - err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: "https://evil.example", Now: now}) - require.ErrorContains(t, err, "origin") - }) - - t.Run("scheme mismatch (http vs https)", func(t *testing.T) { - err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: "http://gateway.example", Now: now}) - require.ErrorContains(t, err, "origin") - }) - - t.Run("verified under wrong registration key", func(t *testing.T) { - // The proof is signed by priv but named keyID; verifying against a - // different registration key must fail the keyid cross-check. - err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: otherID, ExpectedOrigin: origin, Now: now}) - require.ErrorContains(t, err, "keyid") - }) - - t.Run("proof signed by a different key than it names", func(t *testing.T) { - // Craft a proof for origin signed by `other` but the forge expects it - // under `other`'s did:key; a proof that names other but is served for a - // registration under keyID would be caught by the keyid check above. - // Here we confirm a genuine other-signed proof still verifies under its - // own key, so the security rests on binding keyid to the registration. - otherProof, err := SignOwnership(other, origin, now.Unix(), now.Add(time.Hour).Unix()) - require.NoError(t, err) - require.NoError(t, VerifyOwnership(otherProof, nil, OwnershipVerifyConfig{KeyID: otherID, ExpectedOrigin: origin, Now: now})) - // ...but not under keyID. - require.Error(t, VerifyOwnership(otherProof, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now})) - }) - - t.Run("expired", func(t *testing.T) { - err := VerifyOwnership(base, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now.Add(2 * time.Hour)}) - require.ErrorContains(t, err, "expired") - }) - - t.Run("window too long", func(t *testing.T) { - tooLong, err := SignOwnership(priv, origin, now.Unix(), now.Add(MaxOwnershipWindow+time.Hour).Unix()) - require.NoError(t, err) - err = VerifyOwnership(tooLong, nil, OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now}) - require.ErrorContains(t, err, "window") - }) - - t.Run("body tampered", func(t *testing.T) { - err := VerifyOwnership(base, []byte("unexpected body"), OwnershipVerifyConfig{KeyID: keyID, ExpectedOrigin: origin, Now: now}) - require.ErrorContains(t, err, "content-digest") - }) -} diff --git a/internal/ownership/ownership.go b/internal/ownership/ownership.go new file mode 100644 index 0000000..c4f5c6a --- /dev/null +++ b/internal/ownership/ownership.go @@ -0,0 +1,79 @@ +// 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 +) + +// 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). +func Sign(priv ed25519.PrivateKey, origin string, now time.Time, ttl time.Duration) (string, error) { + if ttl <= 0 || ttl > MaxWindow { + ttl = DefaultWindow + } + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + Origin: origin, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now.Add(-backdate)), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + }) + 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 }), + ) + if _, err := parser.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { + return pub, nil + }); err != nil { + return fmt.Errorf("ownership proof invalid: %w", err) + } + if c.IssuedAt == nil || c.ExpiresAt == nil { + return fmt.Errorf("ownership proof missing iat/exp") + } + if c.ExpiresAt.Sub(c.IssuedAt.Time) > MaxWindow+backdate { + 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..d5b6bea --- /dev/null +++ b/internal/ownership/ownership_test.go @@ -0,0 +1,52 @@ +package ownership + +import ( + "crypto/ed25519" + "testing" + "time" + + "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)) + }) +} From b3bba336ea0ecb965393ca883ad61fe95e06f34a Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 23:09:52 +0200 Subject: [PATCH 08/27] refactor: sign v2 requests with yaronf/httpsign Replace the hand-rolled RFC 9421 signer and verifier (~500 lines of canonicalization) with github.com/yaronf/httpsign, a maintained library tested against the spec vectors. internal/httpsig now pins only the profile (covered components, parameters, clock bounds) and the did:key keyid: the client signs with NewEd25519Signer, and the server reads the keyid via RequestDetails, resolves the key from the did:key, and verifies. Content-Digest is generated and validated by the library. The on-the-wire profile (components, parameters, tag) is unchanged. --- acme/verify_v2.go | 81 ++++++ acme/writer_v2.go | 22 +- acme/writer_v2_test.go | 29 +- client/challenge_v2.go | 65 ++++- go.mod | 17 +- go.sum | 35 +++ internal/httpsig/digest.go | 52 ---- internal/httpsig/httpsig.go | 476 ------------------------------- internal/httpsig/httpsig_test.go | 278 ------------------ internal/httpsig/profile.go | 42 +++ 10 files changed, 268 insertions(+), 829 deletions(-) create mode 100644 acme/verify_v2.go delete mode 100644 internal/httpsig/digest.go delete mode 100644 internal/httpsig/httpsig.go delete mode 100644 internal/httpsig/httpsig_test.go create mode 100644 internal/httpsig/profile.go diff --git a/acme/verify_v2.go b/acme/verify_v2.go new file mode 100644 index 0000000..0071f3b --- /dev/null +++ b/acme/verify_v2.go @@ -0,0 +1,81 @@ +package acme + +import ( + "bytes" + "crypto/ed25519" + "errors" + "fmt" + "io" + "net/http" + + "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 + nonce string +} + +// 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) { + // 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("parsing signature: %w", err) + } + if details.KeyID == nil { + return nil, errors.New("signature is missing a keyid") + } + if details.Nonce == nil || len(*details.Nonce) < httpsig.MinNonceLen { + return nil, errors.New("signature is missing a nonce") + } + regPub, err := httpsig.DecodeDIDKey(*details.KeyID) + if err != nil { + return nil, err + } + raw, err := regPub.Raw() + if err != nil { + return nil, fmt.Errorf("reading key: %w", err) + } + + // The Content-Digest must match the body we actually read. + 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("content-digest: %w", err) + } + + // Verify the signature. The verifier requires every covered component, so a + // caller cannot drop one; the tag and freshness window are enforced too. + cfg := httpsign.NewVerifyConfig(). + SetVerifyCreated(true). + SetNotNewerThan(httpsig.MaxForwardDrift). + SetNotOlderThan(httpsig.MaxSignatureLifetime + httpsig.MaxClockSkew). + 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, nonce: *details.Nonce}, nil +} diff --git a/acme/writer_v2.go b/acme/writer_v2.go index 5e2f2d3..5bf0e9f 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -70,26 +70,18 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } - // Verify the signature, digest, freshness window, and authority. This - // yields the authenticated public key; identity is derived from it, never - // from the body. - verified, err := httpsig.VerifyRequest(r, body, httpsig.VerifyConfig{ - Authority: c.Domain, - Now: time.Now(), - }) + // Verify the signature, digest, freshness window, and authority. Identity is + // derived from the signing key in keyid, never from the body. + verified, err := verifyV2Request(r, body, c.Domain) if err != nil { writeProblem(w, http.StatusUnauthorized, "signature-invalid", err.Error()) return } - peerID, err := peer.IDFromPublicKey(verified.PubKey) - if err != nil { - writeProblem(w, http.StatusUnauthorized, "signature-invalid", fmt.Sprintf("deriving peer ID: %s", err)) - return - } + peerID := verified.peerID // Single-use nonce: reject a replayed signed request before the dialback. if c.nonces != nil { - if err := c.nonces.reserve(r.Context(), peerID, verified.Nonce); err != nil { + if err := c.nonces.reserve(r.Context(), peerID, verified.nonce); err != nil { if errors.Is(err, errReplay) { writeProblem(w, http.StatusConflict, "nonce-replayed", "this request has already been submitted") } else { @@ -118,7 +110,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { // 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.KeyID, peerID, typedBody.Addresses, r.Header.Get("User-Agent")) + mode, err := c.verifyReachable(r.Context(), verified.keyID, peerID, 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") @@ -132,7 +124,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, v2Response{ - DID: verified.KeyID, + DID: verified.keyID, Name: certWildcard(peerID, c.ForgeDomain), Verification: v2Verification{Mode: mode}, TTL: int(time.Hour / time.Second), diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index 93e07ef..a73eebf 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -3,6 +3,7 @@ package acme import ( "bytes" "context" + "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" @@ -19,6 +20,7 @@ import ( "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" @@ -55,9 +57,32 @@ func signedV2Request(t *testing.T, priv crypto.PrivKey, value string, addrs []st require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", bytes.NewReader(body)) req.Host = v2TestDomain - params, err := httpsig.NewSignParams(time.Now(), httpsig.MaxSignatureLifetime) + req.Header.Set("Content-Type", "application/json") + + raw, err := priv.Raw() + require.NoError(t, err) + 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) + + nb := make([]byte, 16) + _, err = rand.Read(nb) + require.NoError(t, err) + cfg := httpsign.NewSignConfig().SignCreated(true). + SetExpires(time.Now().Add(httpsig.MaxSignatureLifetime).Unix()). + SetNonce(base64.RawURLEncoding.EncodeToString(nb)). + SetKeyID(keyID). + SetTag(httpsig.RegistrationTag) + signer, err := httpsign.NewEd25519Signer(ed25519.PrivateKey(raw), cfg, httpsign.Headers(httpsig.RegistrationComponents...)) + require.NoError(t, err) + sigInput, sig, err := httpsign.SignRequest(httpsig.SigLabel, *signer, req) require.NoError(t, err) - require.NoError(t, httpsig.SignRequest(req, priv, body, params)) + req.Header.Set("Signature-Input", sigInput) + req.Header.Set("Signature", sig) return req } diff --git a/client/challenge_v2.go b/client/challenge_v2.go index 345150f..ea78c50 100644 --- a/client/challenge_v2.go +++ b/client/challenge_v2.go @@ -3,6 +3,9 @@ package client import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -13,6 +16,7 @@ import ( "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. @@ -70,13 +74,9 @@ func SendChallengeV2(ctx context.Context, baseURL string, privKey crypto.PrivKey } } - params, err := httpsig.NewSignParams(time.Now(), httpsig.MaxSignatureLifetime) - if err != nil { + if err := signV2Request(req, privKey, body); err != nil { return err } - if err := httpsig.SignRequest(req, privKey, body, params); err != nil { - return fmt.Errorf("signing v2 registration request: %w", err) - } httpClient := o.httpClient if httpClient == nil { @@ -124,3 +124,58 @@ func isEd25519(k crypto.PrivKey) bool { _, ok := k.(*crypto.Ed25519PrivateKey) return ok } + +// 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) + + cfg := httpsign.NewSignConfig(). + 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, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/go.mod b/go.mod index 40c7a3e..8700dfb 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ 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 @@ -30,6 +29,7 @@ require ( 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 golang.org/x/time v0.15.0 ) @@ -65,10 +65,12 @@ 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/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 @@ -83,6 +85,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 @@ -138,7 +151,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 c37eadc..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,6 +107,8 @@ 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/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= @@ -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/digest.go b/internal/httpsig/digest.go deleted file mode 100644 index e23dc1d..0000000 --- a/internal/httpsig/digest.go +++ /dev/null @@ -1,52 +0,0 @@ -package httpsig - -import ( - "bytes" - "crypto/sha256" - "fmt" - - "github.com/dunglas/httpsfv" -) - -// contentDigestHeader is the RFC 9530 header carrying the body hash. -const contentDigestHeader = "Content-Digest" - -// ContentDigest returns the RFC 9530 Content-Digest field value for a body, -// using SHA-256, e.g. `sha-256=:Mv9b...=:`. -func ContentDigest(body []byte) (string, error) { - sum := sha256.Sum256(body) - d := httpsfv.NewDictionary() - d.Add("sha-256", httpsfv.NewItem(sum[:])) - s, err := httpsfv.Marshal(d) - if err != nil { - return "", fmt.Errorf("content-digest: marshal: %w", err) - } - return s, nil -} - -// verifyContentDigest checks that the received Content-Digest field value -// carries a sha-256 digest matching body. Additional algorithms are ignored -// per RFC 9530; sha-256 must be present and correct. -func verifyContentDigest(fieldValue string, body []byte) error { - dict, err := httpsfv.UnmarshalDictionary([]string{fieldValue}) - if err != nil { - return fmt.Errorf("content-digest: parse: %w", err) - } - member, ok := dict.Get("sha-256") - if !ok { - return fmt.Errorf("content-digest: missing sha-256") - } - item, ok := member.(httpsfv.Item) - if !ok { - return fmt.Errorf("content-digest: sha-256 is not an item") - } - got, ok := item.Value.([]byte) - if !ok { - return fmt.Errorf("content-digest: sha-256 is not a byte sequence") - } - want := sha256.Sum256(body) - if !bytes.Equal(got, want[:]) { - return fmt.Errorf("content-digest: sha-256 does not match body") - } - return nil -} diff --git a/internal/httpsig/httpsig.go b/internal/httpsig/httpsig.go deleted file mode 100644 index 74d73e6..0000000 --- a/internal/httpsig/httpsig.go +++ /dev/null @@ -1,476 +0,0 @@ -package httpsig - -import ( - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "net" - "net/http" - "strings" - "time" - - "github.com/dunglas/httpsfv" - "github.com/libp2p/go-libp2p/core/crypto" -) - -// Signature-Input / Signature tags identify which fixed profile a signature -// belongs to. The tag is covered by the signature, so it also domain-separates -// a registration signature from an ownership-proof signature and from any -// unrelated RFC 9421 service. -const ( - RegistrationTag = "p2p-forge-reg" - OwnershipTag = "p2p-forge-ownership" -) - -// Freshness bounds for a registration signature. Exported so docs and callers -// reference the constant rather than a drifting literal. -const ( - // MaxSignatureLifetime bounds expires-created. - MaxSignatureLifetime = 5 * time.Minute - // MaxClockSkew is how far in the past `expires` may already be. - MaxClockSkew = 2 * time.Minute - // MaxForwardDrift is how far in the future `created` may be. - MaxForwardDrift = 30 * time.Second -) - -// sigLabel is the fixed Signature-Input / Signature dictionary label. The -// profile carries a single signature per request. -const sigLabel = "sig1" - -// minNonceLen is the minimum accepted nonce length. Callers SHOULD use >=128 -// bits of entropy; GenerateNonce produces 16 random bytes. -const minNonceLen = 16 - -// registrationComponents is the fixed, ordered set of covered components for a -// /v2 registration request. Verification rejects any request covering a -// different set. -var registrationComponents = []string{"@method", "@authority", "@path", "content-type", "content-digest"} - -// SignParams are the per-request RFC 9421 signature parameters chosen by the -// signer. Created/Expires are unix seconds. -type SignParams struct { - Created int64 - Expires int64 - Nonce string -} - -// NewSignParams builds SignParams valid for lifetime starting at now, with a -// fresh random nonce. lifetime is clamped to MaxSignatureLifetime. -func NewSignParams(now time.Time, lifetime time.Duration) (SignParams, error) { - if lifetime <= 0 || lifetime > MaxSignatureLifetime { - lifetime = MaxSignatureLifetime - } - nonce, err := GenerateNonce() - if err != nil { - return SignParams{}, err - } - return SignParams{ - Created: now.Unix(), - Expires: now.Add(lifetime).Unix(), - Nonce: nonce, - }, nil -} - -// GenerateNonce returns a fresh base64url nonce with 128 bits of entropy. -func GenerateNonce() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("generating nonce: %w", err) - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -// SignRequest signs r in place for the registration profile: it sets -// Content-Type (if unset), Content-Digest, Signature-Input and Signature. body -// must be the exact bytes of the request body. -func SignRequest(r *http.Request, priv crypto.PrivKey, body []byte, p SignParams) error { - keyID, err := EncodeDIDKey(priv.GetPublic()) - if err != nil { - return err - } - if len(p.Nonce) < minNonceLen { - return fmt.Errorf("nonce too short (%d < %d)", len(p.Nonce), minNonceLen) - } - - cd, err := ContentDigest(body) - if err != nil { - return err - } - r.Header.Set(contentDigestHeader, cd) - if r.Header.Get("Content-Type") == "" { - r.Header.Set("Content-Type", "application/json") - } - - il := buildInnerList(registrationComponents, sigMeta{ - created: p.Created, - expires: p.Expires, - nonce: p.Nonce, - keyID: keyID, - tag: RegistrationTag, - }) - sigParams, err := httpsfv.Marshal(il) - if err != nil { - return fmt.Errorf("marshal signature params: %w", err) - } - comps, err := deriveComponents(r, registrationComponents) - if err != nil { - return err - } - sig, err := priv.Sign([]byte(signatureBase(comps, sigParams))) - if err != nil { - return fmt.Errorf("signing: %w", err) - } - - inputDict := httpsfv.NewDictionary() - inputDict.Add(sigLabel, il) - inputStr, err := httpsfv.Marshal(inputDict) - if err != nil { - return fmt.Errorf("marshal Signature-Input: %w", err) - } - r.Header.Set("Signature-Input", inputStr) - - sigDict := httpsfv.NewDictionary() - sigDict.Add(sigLabel, httpsfv.NewItem(sig)) - sigStr, err := httpsfv.Marshal(sigDict) - if err != nil { - return fmt.Errorf("marshal Signature: %w", err) - } - r.Header.Set("Signature", sigStr) - return nil -} - -// VerifiedRequest is the authenticated result of a valid registration request. -type VerifiedRequest struct { - PubKey crypto.PubKey - KeyID string - Nonce string - Created int64 - Expires int64 -} - -// VerifyConfig carries the server-side verification inputs. -type VerifyConfig struct { - // Authority is the expected @authority (the registration domain), compared - // after canonicalization. - Authority string - // Now is the reference time (injectable for tests). - Now time.Time -} - -// VerifyRequest verifies r against the fixed registration profile and returns -// the authenticated key. It checks Content-Digest against body, the covered -// component set, the freshness window, the authority, and the signature. The -// caller is responsible for nonce single-use (replay) and rate limiting. -func VerifyRequest(r *http.Request, body []byte, cfg VerifyConfig) (*VerifiedRequest, error) { - wantAuthority := canonicalAuthority(cfg.Authority) - if wantAuthority == "" { - return nil, errors.New("verifier misconfigured: empty authority") - } - - cds := r.Header.Values(contentDigestHeader) - if len(cds) != 1 { - return nil, fmt.Errorf("expected exactly one Content-Digest header, got %d", len(cds)) - } - if err := verifyContentDigest(cds[0], body); err != nil { - return nil, err - } - - inputs := r.Header.Values("Signature-Input") - if len(inputs) != 1 { - return nil, fmt.Errorf("expected exactly one Signature-Input header, got %d", len(inputs)) - } - sigs := r.Header.Values("Signature") - if len(sigs) != 1 { - return nil, fmt.Errorf("expected exactly one Signature header, got %d", len(sigs)) - } - inputDict, err := httpsfv.UnmarshalDictionary([]string{inputs[0]}) - if err != nil { - return nil, fmt.Errorf("parse Signature-Input: %w", err) - } - sigDict, err := httpsfv.UnmarshalDictionary([]string{sigs[0]}) - if err != nil { - return nil, fmt.Errorf("parse Signature: %w", err) - } - - label, il, err := selectByTag(inputDict, RegistrationTag) - if err != nil { - return nil, err - } - if err := checkComponents(il, registrationComponents); err != nil { - return nil, err - } - meta, err := readMeta(il) - if err != nil { - return nil, err - } - if len(meta.nonce) < minNonceLen { - return nil, fmt.Errorf("nonce too short") - } - if err := checkClock(meta.created, meta.expires, cfg.Now); err != nil { - return nil, err - } - pub, err := DecodeDIDKey(meta.keyID) - if err != nil { - return nil, err - } - if got := canonicalAuthority(requestAuthority(r)); got != wantAuthority { - return nil, fmt.Errorf("unexpected authority %q, want %q", got, wantAuthority) - } - - sig, err := signatureBytes(sigDict, label) - if err != nil { - return nil, err - } - sigParams, err := httpsfv.Marshal(il) - if err != nil { - return nil, fmt.Errorf("re-marshal signature params: %w", err) - } - comps, err := deriveComponents(r, registrationComponents) - if err != nil { - return nil, err - } - ok, err := pub.Verify([]byte(signatureBase(comps, sigParams)), sig) - if err != nil { - return nil, fmt.Errorf("verifying signature: %w", err) - } - if !ok { - return nil, errors.New("signature verification failed") - } - return &VerifiedRequest{ - PubKey: pub, - KeyID: meta.keyID, - Nonce: meta.nonce, - Created: meta.created, - Expires: meta.expires, - }, nil -} - -// sigMeta holds the RFC 9421 signature parameters carried in the inner list. -type sigMeta struct { - created int64 - expires int64 - nonce string - keyID string - tag string -} - -// componentValue is a covered component and its derived value. -type componentValue struct { - id string - value string -} - -// signatureBase builds the RFC 9421 signature base: one line per covered -// component, then the @signature-params line with no trailing newline. -func signatureBase(components []componentValue, sigParams string) string { - var b strings.Builder - for _, c := range components { - b.WriteByte('"') - b.WriteString(c.id) - b.WriteString(`": `) - b.WriteString(c.value) - b.WriteByte('\n') - } - b.WriteString(`"@signature-params": `) - b.WriteString(sigParams) - return b.String() -} - -// buildInnerList constructs the Signature-Input inner list (covered components -// plus parameters, in fixed order). -func buildInnerList(ids []string, m sigMeta) httpsfv.InnerList { - items := make([]httpsfv.Item, len(ids)) - for i, id := range ids { - items[i] = httpsfv.NewItem(id) - } - params := httpsfv.NewParams() - params.Add("created", m.created) - params.Add("expires", m.expires) - params.Add("nonce", m.nonce) - params.Add("keyid", m.keyID) - params.Add("tag", m.tag) - return httpsfv.InnerList{Items: items, Params: params} -} - -// deriveComponents resolves each covered component id to its value from r. -func deriveComponents(r *http.Request, ids []string) ([]componentValue, error) { - out := make([]componentValue, len(ids)) - for i, id := range ids { - v, err := deriveComponent(r, id) - if err != nil { - return nil, err - } - out[i] = componentValue{id: id, value: v} - } - return out, nil -} - -func deriveComponent(r *http.Request, id string) (string, error) { - switch id { - case "@method": - return strings.ToUpper(r.Method), nil - case "@authority": - return canonicalAuthority(requestAuthority(r)), nil - case "@path": - p := r.URL.EscapedPath() - if p == "" { - p = "/" - } - return p, nil - default: - vals := r.Header.Values(id) - if len(vals) == 0 { - return "", fmt.Errorf("signature base: missing covered header %q", id) - } - return strings.TrimSpace(strings.Join(vals, ", ")), nil - } -} - -func requestAuthority(r *http.Request) string { - if r.Host != "" { - return r.Host - } - return r.URL.Host -} - -// canonicalAuthority lowercases an authority and strips a default http/https -// port, matching how both signer and verifier must render @authority. -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 -} - -// selectByTag returns the single inner-list member whose tag parameter matches -// want. More than one match, or none, is an error. -func selectByTag(d *httpsfv.Dictionary, want string) (string, httpsfv.InnerList, error) { - var ( - found bool - label string - il httpsfv.InnerList - ) - for _, name := range d.Names() { - member, _ := d.Get(name) - list, ok := member.(httpsfv.InnerList) - if !ok { - continue - } - tag, _ := list.Params.Get("tag") - if ts, _ := tag.(string); ts == want { - if found { - return "", httpsfv.InnerList{}, fmt.Errorf("multiple signatures tagged %q", want) - } - found, label, il = true, name, list - } - } - if !found { - return "", httpsfv.InnerList{}, fmt.Errorf("no signature tagged %q", want) - } - return label, il, nil -} - -// checkComponents verifies the inner list covers exactly want, in order, with -// no per-component parameters. -func checkComponents(il httpsfv.InnerList, want []string) error { - if len(il.Items) != len(want) { - return fmt.Errorf("covered components: got %d, want %d", len(il.Items), len(want)) - } - for i, item := range il.Items { - if len(item.Params.Names()) != 0 { - return fmt.Errorf("covered component %q must not carry parameters", want[i]) - } - got, ok := item.Value.(string) - if !ok { - return fmt.Errorf("covered component %d is not a string", i) - } - if got != want[i] { - return fmt.Errorf("covered component %d: got %q, want %q", i, got, want[i]) - } - } - return nil -} - -// readMeta extracts and type-checks the required signature parameters. -func readMeta(il httpsfv.InnerList) (sigMeta, error) { - var m sigMeta - var err error - if m.created, err = intParam(il.Params, "created"); err != nil { - return m, err - } - if m.expires, err = intParam(il.Params, "expires"); err != nil { - return m, err - } - if m.nonce, err = strParam(il.Params, "nonce"); err != nil { - return m, err - } - if m.keyID, err = strParam(il.Params, "keyid"); err != nil { - return m, err - } - if m.tag, err = strParam(il.Params, "tag"); err != nil { - return m, err - } - return m, nil -} - -func intParam(p *httpsfv.Params, name string) (int64, error) { - v, ok := p.Get(name) - if !ok { - return 0, fmt.Errorf("missing signature parameter %q", name) - } - i, ok := v.(int64) - if !ok { - return 0, fmt.Errorf("signature parameter %q is not an integer", name) - } - return i, nil -} - -func strParam(p *httpsfv.Params, name string) (string, error) { - v, ok := p.Get(name) - if !ok { - return "", fmt.Errorf("missing signature parameter %q", name) - } - s, ok := v.(string) - if !ok { - return "", fmt.Errorf("signature parameter %q is not a string", name) - } - return s, nil -} - -func checkClock(created, expires int64, now time.Time) error { - if created == 0 || expires == 0 { - return errors.New("created and expires are required") - } - if expires <= created { - return errors.New("expires must be after created") - } - if expires-created > int64(MaxSignatureLifetime/time.Second) { - return fmt.Errorf("signature lifetime exceeds %s", MaxSignatureLifetime) - } - ct, et := time.Unix(created, 0), time.Unix(expires, 0) - if ct.After(now.Add(MaxForwardDrift)) { - return errors.New("created is too far in the future") - } - if et.Before(now.Add(-MaxClockSkew)) { - return errors.New("signature has expired") - } - return nil -} - -func signatureBytes(d *httpsfv.Dictionary, label string) ([]byte, error) { - member, ok := d.Get(label) - if !ok { - return nil, fmt.Errorf("Signature missing label %q", label) - } - item, ok := member.(httpsfv.Item) - if !ok { - return nil, fmt.Errorf("Signature %q is not an item", label) - } - b, ok := item.Value.([]byte) - if !ok { - return nil, fmt.Errorf("Signature %q is not a byte sequence", label) - } - return b, nil -} diff --git a/internal/httpsig/httpsig_test.go b/internal/httpsig/httpsig_test.go deleted file mode 100644 index a119e2f..0000000 --- a/internal/httpsig/httpsig_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package httpsig - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "net/http" - "strings" - "testing" - "time" - - "github.com/dunglas/httpsfv" - "github.com/libp2p/go-libp2p/core/crypto" - "github.com/stretchr/testify/require" -) - -const ( - testAuthority = "registration.libp2p.direct" - testURL = "https://registration.libp2p.direct/v2/_acme-challenge" -) - -// fixedKey returns a deterministic Ed25519 key so signatures are reproducible. -func fixedKey(t *testing.T, seed byte) crypto.PrivKey { - t.Helper() - src := bytes.Repeat([]byte{seed}, 64) - priv, _, err := crypto.GenerateEd25519Key(bytes.NewReader(src)) - require.NoError(t, err) - return priv -} - -func newRequest(t *testing.T, body []byte) *http.Request { - t.Helper() - req, err := http.NewRequest(http.MethodPost, testURL, bytes.NewReader(body)) - require.NoError(t, err) - return req -} - -func TestSignVerifyRoundTrip(t *testing.T) { - priv := fixedKey(t, 0x01) - body := []byte(`{"value":"3q2-7w","addresses":["/dns4/example.com/tcp/443/tls/http"]}`) - req := newRequest(t, body) - - now := time.Unix(1_700_000_000, 0) - p, err := NewSignParams(now, time.Minute) - require.NoError(t, err) - require.NoError(t, SignRequest(req, priv, body, p)) - - got, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) - require.NoError(t, err) - - wantID, err := EncodeDIDKey(priv.GetPublic()) - require.NoError(t, err) - require.Equal(t, wantID, got.KeyID) - require.True(t, got.PubKey.Equals(priv.GetPublic())) - require.Equal(t, p.Nonce, got.Nonce) -} - -// TestSignatureBaseGolden pins the exact RFC 9421 signature base bytes so a -// canonicalization change cannot pass unnoticed and cross-language signers can -// reproduce it. -func TestSignatureBaseGolden(t *testing.T) { - priv := fixedKey(t, 0x01) - keyID, err := EncodeDIDKey(priv.GetPublic()) - require.NoError(t, err) - - body := []byte(`{"value":"abc"}`) - req := newRequest(t, body) - cd, err := ContentDigest(body) - require.NoError(t, err) - req.Header.Set(contentDigestHeader, cd) - req.Header.Set("Content-Type", "application/json") - - il := buildInnerList(registrationComponents, sigMeta{ - created: 1_700_000_000, - expires: 1_700_000_060, - nonce: "dGVzdG5vbmNlMTIzNA", - keyID: keyID, - tag: RegistrationTag, - }) - sigParams, err := httpsfv.Marshal(il) - require.NoError(t, err) - comps, err := deriveComponents(req, registrationComponents) - require.NoError(t, err) - base := signatureBase(comps, sigParams) - - sum := sha256.Sum256(body) - wantDigest := "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" - require.Equal(t, wantDigest, cd, "Content-Digest format") - - want := strings.Join([]string{ - `"@method": POST`, - `"@authority": registration.libp2p.direct`, - `"@path": /v2/_acme-challenge`, - `"content-type": application/json`, - `"content-digest": ` + wantDigest, - `"@signature-params": ("@method" "@authority" "@path" "content-type" "content-digest");created=1700000000;expires=1700000060;nonce="dGVzdG5vbmNlMTIzNA";keyid="` + keyID + `";tag="p2p-forge-reg"`, - }, "\n") - require.Equal(t, want, base) -} - -func TestVerifyRejectsTamper(t *testing.T) { - priv := fixedKey(t, 0x02) - body := []byte(`{"value":"xyz"}`) - now := time.Unix(1_700_000_000, 0) - - sign := func() *http.Request { - req := newRequest(t, body) - p, err := NewSignParams(now, time.Minute) - require.NoError(t, err) - require.NoError(t, SignRequest(req, priv, body, p)) - return req - } - - t.Run("body changed", func(t *testing.T) { - req := sign() - _, err := VerifyRequest(req, []byte(`{"value":"XYZ"}`), VerifyConfig{Authority: testAuthority, Now: now}) - require.ErrorContains(t, err, "content-digest") - }) - - t.Run("method changed", func(t *testing.T) { - req := sign() - req.Method = http.MethodPut - _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) - require.ErrorContains(t, err, "signature verification failed") - }) - - t.Run("covered header changed", func(t *testing.T) { - req := sign() - req.Header.Set("Content-Type", "text/plain") - _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) - require.ErrorContains(t, err, "signature verification failed") - }) - - t.Run("wrong authority", func(t *testing.T) { - req := sign() - _, err := VerifyRequest(req, body, VerifyConfig{Authority: "evil.example", Now: now}) - require.ErrorContains(t, err, "unexpected authority") - }) - - t.Run("signature bytes flipped", func(t *testing.T) { - req := sign() - sig := req.Header.Get("Signature") - req.Header.Set("Signature", strings.Replace(sig, "sig1=:", "sig1=:AA", 1)) - _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) - require.Error(t, err) - }) -} - -func TestVerifyClockWindow(t *testing.T) { - priv := fixedKey(t, 0x03) - body := []byte(`{}`) - signAt := time.Unix(1_700_000_000, 0) - - req := newRequest(t, body) - p, err := NewSignParams(signAt, time.Minute) - require.NoError(t, err) - require.NoError(t, SignRequest(req, priv, body, p)) - - t.Run("expired", func(t *testing.T) { - _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: signAt.Add(MaxClockSkew + 2*time.Minute)}) - require.ErrorContains(t, err, "expired") - }) - - t.Run("created in future", func(t *testing.T) { - _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: signAt.Add(-time.Hour)}) - require.ErrorContains(t, err, "future") - }) - - t.Run("within skew ok", func(t *testing.T) { - _, err := VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: signAt.Add(30 * time.Second)}) - require.NoError(t, err) - }) -} - -func TestVerifyRejectsWrongProfile(t *testing.T) { - priv := fixedKey(t, 0x04) - body := []byte(`{}`) - now := time.Unix(1_700_000_000, 0) - - t.Run("wrong tag", func(t *testing.T) { - req := newRequest(t, body) - cd, err := ContentDigest(body) - require.NoError(t, err) - req.Header.Set(contentDigestHeader, cd) - req.Header.Set("Content-Type", "application/json") - il := buildInnerList(registrationComponents, sigMeta{ - created: now.Unix(), expires: now.Add(time.Minute).Unix(), - nonce: "dGVzdG5vbmNlMTIzNA", keyID: mustDID(t, priv), tag: "some-other-service", - }) - signInto(t, req, priv, il) - _, err = VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) - require.ErrorContains(t, err, "no signature tagged") - }) - - t.Run("missing covered component", func(t *testing.T) { - req := newRequest(t, body) - cd, err := ContentDigest(body) - require.NoError(t, err) - req.Header.Set(contentDigestHeader, cd) - req.Header.Set("Content-Type", "application/json") - shortSet := []string{"@method", "@authority", "@path", "content-digest"} // drops content-type - il := buildInnerList(shortSet, sigMeta{ - created: now.Unix(), expires: now.Add(time.Minute).Unix(), - nonce: "dGVzdG5vbmNlMTIzNA", keyID: mustDID(t, priv), tag: RegistrationTag, - }) - signInto(t, req, priv, il) - _, err = VerifyRequest(req, body, VerifyConfig{Authority: testAuthority, Now: now}) - require.ErrorContains(t, err, "covered components") - }) -} - -func TestVerifyAuthority(t *testing.T) { - priv := fixedKey(t, 0x05) - body := []byte(`{}`) - now := time.Unix(1_700_000_000, 0) - req := newRequest(t, body) - p, err := NewSignParams(now, time.Minute) - require.NoError(t, err) - require.NoError(t, SignRequest(req, priv, body, p)) - - t.Run("empty configured authority is a misconfig", func(t *testing.T) { - _, err := VerifyRequest(req, body, VerifyConfig{Authority: "", Now: now}) - require.ErrorContains(t, err, "empty authority") - }) - - t.Run("configured authority is canonicalized", func(t *testing.T) { - // Uppercase + explicit default port must still match the request. - _, err := VerifyRequest(req, body, VerifyConfig{Authority: strings.ToUpper(testAuthority) + ":443", Now: now}) - require.NoError(t, err) - }) -} - -func TestContentDigestVerify(t *testing.T) { - body := []byte("hello world") - cd, err := ContentDigest(body) - require.NoError(t, err) - require.NoError(t, verifyContentDigest(cd, body)) - require.Error(t, verifyContentDigest(cd, []byte("hello world!"))) - require.ErrorContains(t, verifyContentDigest(`md5=:xxxx:`, body), "sha-256") -} - -// mustDID and signInto are test helpers that build a signature from a -// caller-supplied inner list so tests can exercise malformed profiles. -func mustDID(t *testing.T, priv crypto.PrivKey) string { - t.Helper() - id, err := EncodeDIDKey(priv.GetPublic()) - require.NoError(t, err) - return id -} - -// signInto signs req with a caller-supplied inner list, so tests can craft -// malformed profiles (wrong tag, missing component) that SignRequest would not -// produce. -func signInto(t *testing.T, req *http.Request, priv crypto.PrivKey, il httpsfv.InnerList) { - t.Helper() - sigParams, err := httpsfv.Marshal(il) - require.NoError(t, err) - ids := make([]string, len(il.Items)) - for i, item := range il.Items { - ids[i] = item.Value.(string) - } - comps, err := deriveComponents(req, ids) - require.NoError(t, err) - sig, err := priv.Sign([]byte(signatureBase(comps, sigParams))) - require.NoError(t, err) - - inputDict := httpsfv.NewDictionary() - inputDict.Add(sigLabel, il) - inputStr, err := httpsfv.Marshal(inputDict) - require.NoError(t, err) - req.Header.Set("Signature-Input", inputStr) - - sigDict := httpsfv.NewDictionary() - sigDict.Add(sigLabel, httpsfv.NewItem(sig)) - sigStr, err := httpsfv.Marshal(sigDict) - require.NoError(t, err) - req.Header.Set("Signature", sigStr) -} diff --git a/internal/httpsig/profile.go b/internal/httpsig/profile.go new file mode 100644 index 0000000..9cee71a --- /dev/null +++ b/internal/httpsig/profile.go @@ -0,0 +1,42 @@ +// 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. + MaxSignatureLifetime = 5 * time.Minute + // MaxClockSkew is how far in the past `expires` may already be. + MaxClockSkew = 2 * time.Minute + // MaxForwardDrift is how far in the future `created` may be. + MaxForwardDrift = 30 * time.Second + // MinNonceLen is the minimum accepted nonce length in base64url characters. + MinNonceLen = 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 +} From 01dd4d3d297a05dd748ddde8aeeacf5b897e706d Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 15 Jul 2026 23:12:32 +0200 Subject: [PATCH 09/27] refactor: remove the per-IP rate limiter Drop the in-memory per-source-IP token bucket and its handler gate. Request rate limiting belongs on the fronting reverse proxy, CDN, or load balancer, which libp2p.direct already runs behind; a per-instance limiter duplicates that and multiplies by the backend count. The nonce store (replay protection, which a proxy cannot do) and the denylist stay. Docs now say rate limiting is the operator's proxy responsibility. --- README.md | 2 +- acme/antiabuse.go | 105 +++------------------------------------- acme/antiabuse_test.go | 53 -------------------- acme/writer.go | 3 +- acme/writer_v2.go | 7 --- docs/registration-v2.md | 18 ++++--- go.mod | 2 +- 7 files changed, 20 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index 08599de..001a5e1 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ acme FORGE_DOMAIN { - **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 destination-IP vetting, the address cap, and the dial timeout on the reachability probe. 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 rate limiting and denylist. Without it, only the direct connection address is trusted; a forged `X-Forwarded-For` is ignored. +- **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. Without it, only the direct connection address is trusted; a forged `X-Forwarded-For` is ignored. 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) diff --git a/acme/antiabuse.go b/acme/antiabuse.go index dcd6ee1..c2f1a6c 100644 --- a/acme/antiabuse.go +++ b/acme/antiabuse.go @@ -6,125 +6,34 @@ import ( "encoding/hex" "errors" "fmt" - "net" - "net/http" - "net/netip" - "strings" "sync" "time" "github.com/ipfs/go-datastore" "github.com/ipshipyard/p2p-forge/internal/httpsig" "github.com/libp2p/go-libp2p/core/peer" - "golang.org/x/time/rate" ) -// Anti-abuse tunables. Exported-style named constants so docs and operators can -// reference them rather than a drifting literal. -const ( - // nonceTTL is how long a used nonce is remembered. It must exceed the - // maximum signature lifetime plus clock skew on both sides so a nonce - // cannot expire from the store while its signature is still valid. - nonceTTL = 20 * time.Minute - - // registrationsPerMinute and registrationBurst bound registrations per - // source IP. A cert order needs ~2 requests, so this is generous for - // legitimate renewals while capping abuse. - registrationsPerMinute = 10 - registrationBurst = 20 - - // rateLimiterEntryTTL evicts idle per-IP limiters so the map stays bounded. - rateLimiterEntryTTL = 15 * time.Minute -) +// nonceTTL is how long a used nonce is remembered. It must exceed the maximum +// signature lifetime plus clock skew on both sides so a nonce cannot expire from +// the store while its signature is still valid. +const nonceTTL = 20 * time.Minute // errReplay signals a nonce that has already been used. var errReplay = errors.New("nonce already used") -// initAntiAbuse constructs the per-instance rate limiter and the nonce store. -// It is idempotent and safe to call from OnStartup and from tests. +// initAntiAbuse constructs the per-instance nonce store. Request rate limiting +// is intentionally not done here; it belongs on the fronting reverse proxy, CDN, +// or load balancer. It is idempotent and safe to call from OnStartup and tests. func (c *acmeWriter) initAntiAbuse() { - // A nonce must outlive the longest window in which its signature is still - // valid, or a replay could land after the nonce expired from the store. if min := httpsig.MaxSignatureLifetime + 2*httpsig.MaxClockSkew; nonceTTL <= min { panic(fmt.Sprintf("nonceTTL (%s) must exceed max signature lifetime + 2x skew (%s)", nonceTTL, min)) } - if c.rateLimiter == nil { - c.rateLimiter = newIPRateLimiter(registrationsPerMinute, registrationBurst, rateLimiterEntryTTL) - } if c.nonces == nil && c.Datastore != nil { c.nonces = newNonceStore(c.Datastore, nonceTTL) } } -// primaryClientIP returns the single client IP used for rate limiting: the -// configured trusted header when set (e.g. CF-Connecting-IP behind Cloudflare), -// otherwise the direct connection address. It never trusts a leftmost -// X-Forwarded-For, which any client can forge. -func primaryClientIP(r *http.Request, trustedHeader string) (netip.Addr, bool) { - if trustedHeader != "" { - if v := strings.TrimSpace(r.Header.Get(trustedHeader)); v != "" { - if ip, err := netip.ParseAddr(v); err == nil { - return ip, true - } - } - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr - } - ip, err := netip.ParseAddr(host) - return ip, err == nil -} - -// ipRateLimiter is a per-source-IP token-bucket limiter with lazy eviction of -// idle buckets. It is in-memory per instance; behind a load balancer the real -// cap is the front proxy plus the shared datastore-backed guards. -type ipRateLimiter struct { - mu sync.Mutex - buckets map[netip.Addr]*rateBucket - limit rate.Limit - burst int - entryTTL time.Duration - lastGC time.Time -} - -type rateBucket struct { - limiter *rate.Limiter - seen time.Time -} - -func newIPRateLimiter(perMinute, burst int, entryTTL time.Duration) *ipRateLimiter { - return &ipRateLimiter{ - buckets: make(map[netip.Addr]*rateBucket), - limit: rate.Every(time.Minute / time.Duration(perMinute)), - burst: burst, - entryTTL: entryTTL, - } -} - -// allow reports whether a request from ip is permitted at time now. -func (rl *ipRateLimiter) allow(ip netip.Addr, now time.Time) bool { - rl.mu.Lock() - defer rl.mu.Unlock() - - if now.Sub(rl.lastGC) > rl.entryTTL { - for k, b := range rl.buckets { - if now.Sub(b.seen) > rl.entryTTL { - delete(rl.buckets, k) - } - } - rl.lastGC = now - } - - b := rl.buckets[ip] - if b == nil { - b = &rateBucket{limiter: rate.NewLimiter(rl.limit, rl.burst)} - rl.buckets[ip] = b - } - b.seen = now - return b.limiter.AllowN(now, 1) -} - // nonceStore records used nonces so a captured signed request cannot be // replayed within its validity window. Reservation is atomic within one // instance via the mutex; across load-balanced instances two truly simultaneous diff --git a/acme/antiabuse_test.go b/acme/antiabuse_test.go index e5ce1f1..148d71b 100644 --- a/acme/antiabuse_test.go +++ b/acme/antiabuse_test.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "net/http" "net/http/httptest" - "net/netip" "testing" "time" @@ -14,20 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestIPRateLimiter(t *testing.T) { - rl := newIPRateLimiter(60, 2, time.Minute) // 1/sec, burst 2 - ip := netip.MustParseAddr("203.0.113.4") - now := time.Unix(1_700_000_000, 0) - - require.True(t, rl.allow(ip, now)) - require.True(t, rl.allow(ip, now)) - require.False(t, rl.allow(ip, now), "burst exhausted") - require.True(t, rl.allow(ip, now.Add(time.Second)), "refilled after 1s") - - // A different IP has its own bucket. - require.True(t, rl.allow(netip.MustParseAddr("198.51.100.9"), now)) -} - func TestNonceStoreReplay(t *testing.T) { ns := newNonceStore(ttlDatastore{datastore.NewMapDatastore()}, time.Minute) _, priv := newRegistrantHost(t) @@ -39,21 +24,6 @@ func TestNonceStoreReplay(t *testing.T) { require.NoError(t, ns.reserve(t.Context(), pid, "nonce-xyz"), "different nonce is fresh") } -func TestPrimaryClientIP(t *testing.T) { - t.Run("no trusted header uses RemoteAddr, ignores XFF", func(t *testing.T) { - r := &http.Request{Header: http.Header{"X-Forwarded-For": {"6.6.6.6"}}, RemoteAddr: "9.9.9.9:80"} - ip, ok := primaryClientIP(r, "") - require.True(t, ok) - require.Equal(t, "9.9.9.9", ip.String()) - }) - t.Run("trusted header wins", func(t *testing.T) { - r := &http.Request{Header: http.Header{"Cf-Connecting-Ip": {"1.2.3.4"}}, RemoteAddr: "9.9.9.9:80"} - ip, ok := primaryClientIP(r, "CF-Connecting-IP") - require.True(t, ok) - require.Equal(t, "1.2.3.4", ip.String()) - }) -} - func TestV2NonceReplayRejected(t *testing.T) { initMetrics() h, priv := newRegistrantHost(t) @@ -78,29 +48,6 @@ func TestV2NonceReplayRejected(t *testing.T) { require.Equal(t, http.StatusConflict, replay().Code, "replay of the same nonce is rejected") } -func TestV2RateLimited(t *testing.T) { - initMetrics() - c := newTestWriter() - c.rateLimiter = newIPRateLimiter(1, 1, time.Minute) // burst 1 - - // Requests need no valid signature: the rate limit runs before verification. - mk := func() *http.Request { - r := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", nil) - r.Host = v2TestDomain - r.RemoteAddr = "203.0.113.7:2222" - return r - } - - first := httptest.NewRecorder() - c.handleV2Challenge(first, mk()) - require.NotEqual(t, http.StatusTooManyRequests, first.Code, "first passes the limiter") - - second := httptest.NewRecorder() - c.handleV2Challenge(second, mk()) - require.Equal(t, http.StatusTooManyRequests, second.Code) - require.NotEmpty(t, second.Header().Get("Retry-After")) -} - // readAndRestore reads a request body and refills it so the request stays usable. func readAndRestore(r *http.Request) ([]byte, error) { buf := new(bytes.Buffer) diff --git a/acme/writer.go b/acme/writer.go index 291ff85..676b28b 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -60,8 +60,7 @@ type acmeWriter struct { Datastore datastore.TTLDatastore - rateLimiter *ipRateLimiter - nonces *nonceStore + nonces *nonceStore ln net.Listener nlSetup bool diff --git a/acme/writer_v2.go b/acme/writer_v2.go index 5bf0e9f..c7f0965 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -52,13 +52,6 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } - // Per-source-IP rate limit, ahead of the asymmetric signature verify. - if ip, ok := primaryClientIP(r, c.ClientIPHeader); ok && c.rateLimiter != nil && !c.rateLimiter.allow(ip, time.Now()) { - w.Header().Set("Retry-After", "60") - writeProblem(w, http.StatusTooManyRequests, "rate-limited", "too many registrations from your address") - return - } - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxV2BodySize)) if err != nil { var maxErr *http.MaxBytesError diff --git a/docs/registration-v2.md b/docs/registration-v2.md index c46855b..e5a68ae 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -235,17 +235,19 @@ below. | `409` | Nonce already used (replay). | | `413` | Body too large. | | `422` | No submitted address could be verified. | -| `429` | Rate limited. Carries `Retry-After`. | + +A fronting proxy may add others, such as `429` when it rate-limits. ## Anti-abuse -- **Rate limiting** SHOULD run per source IP, before the signature is verified. - The forge MUST NOT trust a leftmost `X-Forwarded-For`, 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`). +- **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 MUST treat each `nonce` as single-use and reject a reused one, so a captured request cannot be replayed within its window. -- **Denylist** applies to the client IP and to every resolved endpoint IP. +- **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 @@ -256,8 +258,8 @@ Two `acme` block options affect `/v2` (see the README for full syntax): 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). Without it, - only the direct connection address is trusted for rate limiting and denylist. + real client IP (for example `CF-Connecting-IP` behind Cloudflare), used for the + denylist. Without it, only the direct connection address is trusted. ## Adding a key type diff --git a/go.mod b/go.mod index 8700dfb..11c3b4e 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,6 @@ require ( github.com/stretchr/testify v1.11.1 github.com/yaronf/httpsign v0.5.2 go.uber.org/zap v1.28.0 - golang.org/x/time v0.15.0 ) require ( @@ -174,6 +173,7 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect From fb2c3271282ce0d13f2b566c094ad81fa0be1708 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 17 Jul 2026 20:00:34 +0200 Subject: [PATCH 10/27] fix: enforce the full v2 signature profile The verifier library treats expires as optional and puts no bound on expires-created, so the server enforced less than the spec promised. - require created and expires, bound expires-created by the 5-minute MaxSignatureLifetime, raise the nonce minimum to 22 base64url chars - split profile-grammar failures (400 malformed-signature) from authentication failures (401 signature-invalid); a body that contradicts its own digest is malformed, so 400 - drop the never-populated verification.addr response field - enumerate every problem type fragment in the spec errors table, point the type URIs at the docs, and fix the 104-bit example nonce - pin the 401 side with expired-signature and wrong-keyid tests --- acme/verify_v2.go | 41 +++++++++++--- acme/writer_v2.go | 17 ++++-- acme/writer_v2_test.go | 108 ++++++++++++++++++++++++++++++++---- docs/registration-v2.md | 37 +++++++----- internal/httpsig/profile.go | 10 +++- 5 files changed, 172 insertions(+), 41 deletions(-) diff --git a/acme/verify_v2.go b/acme/verify_v2.go index 0071f3b..2c1b7f7 100644 --- a/acme/verify_v2.go +++ b/acme/verify_v2.go @@ -20,6 +20,11 @@ type v2Verified struct { nonce string } +// 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 @@ -29,27 +34,47 @@ func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, // 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("parsing signature: %w", err) + return nil, fmt.Errorf("%w: parsing signature: %w", errMalformed, err) } if details.KeyID == nil { - return nil, errors.New("signature is missing a keyid") + return nil, fmt.Errorf("%w: signature is missing a keyid", errMalformed) + } + if details.Nonce == nil { + return nil, fmt.Errorf("%w: signature is missing a nonce", errMalformed) + } + if len(*details.Nonce) < httpsig.MinNonceLen { + return nil, fmt.Errorf("%w: nonce is shorter than %d base64url characters", errMalformed, httpsig.MinNonceLen) + } + // 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.Nonce == nil || len(*details.Nonce) < httpsig.MinNonceLen { - return nil, errors.New("signature is missing a nonce") + 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, err + return nil, fmt.Errorf("%w: %w", errMalformed, err) } raw, err := regPub.Raw() if err != nil { - return nil, fmt.Errorf("reading key: %w", err) + return nil, fmt.Errorf("%w: reading key: %w", errMalformed, err) } - // The Content-Digest must match the body we actually read. + // 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("content-digest: %w", err) + return nil, fmt.Errorf("%w: content-digest: %w", errMalformed, err) } // Verify the signature. The verifier requires every covered component, so a diff --git a/acme/writer_v2.go b/acme/writer_v2.go index c7f0965..b866465 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -64,10 +64,16 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { } // Verify the signature, digest, freshness window, and authority. Identity is - // derived from the signing key in keyid, never from the body. + // 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 { - writeProblem(w, http.StatusUnauthorized, "signature-invalid", err.Error()) + 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 @@ -150,7 +156,6 @@ type v2Response struct { type v2Verification struct { Mode string `json:"mode"` - Addr string `json:"addr,omitempty"` } type v2Profile struct { @@ -206,12 +211,16 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = 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. func writeProblem(w http.ResponseWriter, status int, problemType, detail string) { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{ - "type": "https://specs.ipfs.tech/p2p-forge/v2/errors#" + problemType, + "type": problemTypeBase + problemType, "title": http.StatusText(status), "status": status, "detail": detail, diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index a73eebf..a1e2e09 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -52,6 +52,20 @@ func newRegistrantHost(t *testing.T) (host.Host, crypto.PrivKey) { } 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 +} + +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) @@ -61,22 +75,35 @@ func signedV2Request(t *testing.T, priv crypto.PrivKey, value string, addrs []st raw, err := priv.Raw() require.NoError(t, err) - keyID, err := httpsig.EncodeDIDKey(priv.GetPublic()) - 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) - nb := make([]byte, 16) - _, err = rand.Read(nb) - require.NoError(t, err) - cfg := httpsign.NewSignConfig().SignCreated(true). - SetExpires(time.Now().Add(httpsig.MaxSignatureLifetime).Unix()). - SetNonce(base64.RawURLEncoding.EncodeToString(nb)). + 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()) + } signer, err := httpsign.NewEd25519Signer(ed25519.PrivateKey(raw), cfg, httpsign.Headers(httpsig.RegistrationComponents...)) require.NoError(t, err) sigInput, sig, err := httpsign.SignRequest(httpsig.SigLabel, *signer, req) @@ -140,14 +167,73 @@ func TestV2ChallengeHandlerRejects(t *testing.T) { 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 (covered by the signature) - // no longer matches. + // 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.StatusUnauthorized, rec.Code) + 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 encode to 16 base64url characters, under the 22 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("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 authority", func(t *testing.T) { diff --git a/docs/registration-v2.md b/docs/registration-v2.md index e5a68ae..cdb1859 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -92,9 +92,10 @@ 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 or an -`expires` more than 2 minutes in the past (clock skew). A client with a fast -clock SHOULD NOT sign `created` far ahead of real time. +The server MUST reject a `created` more than 30 seconds in the future, a +`created` older than 7 minutes (the 5-minute maximum lifetime plus 2 minutes of +allowance for a slow client clock), and an `expires` that has already passed. A +client with a fast clock SHOULD NOT sign `created` far ahead of real time. ### Signature base @@ -108,13 +109,13 @@ trailing newline. For a POST to `registration.libp2p.direct` it looks like: "@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="dGVzdG5vbmNlMTIzNA";keyid="did:key:z6Mk...";tag="p2p-forge-reg" +"@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 use the label `sig1`: ``` -Signature-Input: sig1=("@method" "@authority" "@path" "content-type" "content-digest");created=1700000000;expires=1700000060;nonce="dGVzdG5vbmNlMTIzNA";keyid="did:key:z6Mk...";tag="p2p-forge-reg" +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=:: ``` @@ -224,17 +225,23 @@ 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) -with a distinct `type` per class, and MUST use a status consistent with the table -below. +and MUST use a status consistent with the table below. Each error class carries +a stable fragment at the end of its `type` URI; a client that needs to tell +classes apart SHOULD match on that fragment. -| Status | When | -| --- | --- | -| `400` | Malformed body, digest, signature, address, or a query string. | -| `401` | Signature invalid, or outside the clock window. | -| `403` | Denylisted, or missing `Forge-Authorization` when required. | -| `409` | Nonce already used (replay). | -| `413` | Body too large. | -| `422` | No submitted address could be verified. | +| Status | `type` fragment | When | +| --- | --- | --- | +| `400` | `unexpected-query` | The request carries a query string. | +| `400` | `malformed-body` | 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 bad `did:key`, a nonce under 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` | `forbidden` | Missing or wrong `Forge-Authorization` where the operator requires one. | +| `403` | `denylisted` | The client IP or a submitted address is denylisted. | +| `409` | `nonce-replayed` | The nonce was already used (replay). | +| `413` | `body-too-large` | The body exceeds 8 KiB. | +| `422` | `verification-failed` | No submitted address could be verified. | +| `500` | `misconfigured`, `nonce-store-error`, `storage-error` | Server-side failure; safe to retry later. | A fronting proxy may add others, such as `429` when it rate-limits. diff --git a/internal/httpsig/profile.go b/internal/httpsig/profile.go index 9cee71a..8482a48 100644 --- a/internal/httpsig/profile.go +++ b/internal/httpsig/profile.go @@ -19,12 +19,16 @@ const ( // MaxSignatureLifetime bounds expires-created. MaxSignatureLifetime = 5 * time.Minute - // MaxClockSkew is how far in the past `expires` may already be. + // MaxClockSkew extends the accepted age of `created` beyond + // MaxSignatureLifetime, tolerating a slow client clock. `expires` gets no + // such grace: a signature past its own deadline is rejected outright. MaxClockSkew = 2 * time.Minute // MaxForwardDrift is how far in the future `created` may be. MaxForwardDrift = 30 * time.Second - // MinNonceLen is the minimum accepted nonce length in base64url characters. - MinNonceLen = 16 + // MinNonceLen is the minimum accepted nonce length in base64url + // characters: 22 characters is the shortest encoding of the required 128 + // bits of entropy. + MinNonceLen = 22 ) // RegistrationComponents is the fixed, ordered set of covered components for a From 05e268b3b990c9bb221c013b160804c09a468cbe Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 17 Jul 2026 20:01:05 +0200 Subject: [PATCH 11/27] fix: canonicalize IP-literal proof origins An IPv6 literal produced an unbracketed origin ("https://2001:db8::1:443") that failed URL parsing when the forge fetched the ownership proof, so IPv6-literal registrations always failed verification. - bracket IPv6 hosts via net.JoinHostPort and collapse IP literals to one canonical textual form, on signer and verifier alike - reject zoned literals (fe80::1%eth0): host-local, never publicly verifiable, and the "%" is not URL-safe - end-to-end proof test on [::1] pinning the bracketed form --- acme/ownership_test.go | 47 +++++++++++++++++++++++++++++++++++++++++ client/ownership.go | 17 +++++++++++++-- docs/registration-v2.md | 4 ++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/acme/ownership_test.go b/acme/ownership_test.go index 6ad239c..f28b88c 100644 --- a/acme/ownership_test.go +++ b/acme/ownership_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "net" "net/http" "net/http/httptest" "testing" @@ -106,6 +107,7 @@ func TestCanonicalOriginServer(t *testing.T) { "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) @@ -113,4 +115,49 @@ func TestCanonicalOriginServer(t *testing.T) { 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(), did, []string{srv.URL})) } diff --git a/client/ownership.go b/client/ownership.go index c654820..cb6160a 100644 --- a/client/ownership.go +++ b/client/ownership.go @@ -4,7 +4,9 @@ import ( "crypto/ed25519" "fmt" "io" + "net" "net/http" + "net/netip" "net/url" "strings" "sync" @@ -31,7 +33,10 @@ type HTTPOrigin struct { // 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. +// 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 { @@ -53,6 +58,14 @@ func CanonicalOrigin(rawURL string) (HTTPOrigin, error) { 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" { @@ -62,7 +75,7 @@ func CanonicalOrigin(rawURL string) (HTTPOrigin, error) { } } return HTTPOrigin{ - Origin: u.Scheme + "://" + host + ":" + port, + Origin: u.Scheme + "://" + net.JoinHostPort(host, port), Scheme: u.Scheme, Host: host, Port: port, diff --git a/docs/registration-v2.md b/docs/registration-v2.md index cdb1859..28d8bc1 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -186,6 +186,10 @@ so it is cacheable and can be signed offline. The JWT header MUST use | `iat` | Issued-at, unix seconds. | | `exp` | Expiry, unix seconds. `exp - iat` MUST NOT exceed 14 days. | +`origin` uses the lowercase host and an explicit port. An IP-literal host is in +its canonical textual form, and an IPv6 literal is bracketed, for example +`https://[2001:db8::1]:443`. + 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 the origin it connected to (scheme, host, and port are all bound, so an From 41fab00d827feb727e472be4721e7b5150a4f1b6 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 17 Jul 2026 20:01:58 +0200 Subject: [PATCH 12/27] fix: constant-time forge auth comparison The optional shared-secret gate compared tokens with plain string equality, a timing oracle. Hash both sides and compare with crypto/subtle in the v1 and v2 handlers, and cover the refusal path, which no test exercised. --- acme/writer.go | 12 +++++++++++- acme/writer_v2.go | 2 +- acme/writer_v2_test.go | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/acme/writer.go b/acme/writer.go index 676b28b..4c704ed 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" @@ -128,7 +130,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 @@ -282,6 +284,14 @@ 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"` diff --git a/acme/writer_v2.go b/acme/writer_v2.go index b866465..ee373a2 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -47,7 +47,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { } // Cheapest gate first: optional shared-secret access token. - if c.forgeAuthKey != "" && r.Header.Get(client.ForgeAuthHeader) != c.forgeAuthKey { + if c.forgeAuthKey != "" && !constantTimeEqual(r.Header.Get(client.ForgeAuthHeader), c.forgeAuthKey) { writeProblem(w, http.StatusForbidden, "forbidden", fmt.Sprintf("missing or invalid %s header", client.ForgeAuthHeader)) return } diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index a1e2e09..ccc7ff4 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -14,6 +14,7 @@ import ( "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" @@ -236,6 +237,24 @@ func TestV2ChallengeHandlerRejects(t *testing.T) { 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" + + missing := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", nil) + missing.Host = v2TestDomain + rec := httptest.NewRecorder() + c.handleV2Challenge(rec, missing) + require.Equal(t, http.StatusForbidden, rec.Code, "missing token") + + wrong := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", nil) + wrong.Host = v2TestDomain + wrong.Header.Set(client.ForgeAuthHeader, "not-the-secret") + rec = httptest.NewRecorder() + c.handleV2Challenge(rec, wrong) + require.Equal(t, http.StatusForbidden, rec.Code, "wrong token") + }) + t.Run("wrong authority", func(t *testing.T) { h, priv := newRegistrantHost(t) req := signedV2Request(t, priv, value, addrStrings(h)) From 1c0bcd69f8b411b7b0604ff717de216cff8414b3 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 17 Jul 2026 20:02:08 +0200 Subject: [PATCH 13/27] chore: changelog heading and comment fixes Remove a duplicate empty "### Fixed" heading under Unreleased and reword a v2 e2e comment that described the history instead of the final state. --- CHANGELOG.md | 2 -- e2e_test.go | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bfdfc..e5eadc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. -### Fixed - ## [v0.9.1] - 2026-06-22 ### Fixed diff --git a/e2e_test.go b/e2e_test.go index 7148565..c7b2013 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -315,7 +315,7 @@ func TestSetACMEChallenge(t *testing.T) { } // TestSetACMEChallengeV2 exercises the full v2 stack: an RFC 9421-signed -// registration (no libp2p PeerID-auth handshake), the hardened-later dialback, +// registration (no libp2p PeerID-auth handshake), the SSRF-hardened dialback, // and the unchanged DNS-01 TXT readback. func TestSetACMEChallengeV2(t *testing.T) { t.Parallel() From 3566ede585da309e2e80adfe6f949a5966a4dde8 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 14:09:33 +0200 Subject: [PATCH 14/27] fix(acme): drop webpki attempt from ownership fetch The https proof fetch tried WebPKI verification first and retried without it on a cert error. The first attempt had no observable effect: a valid cert never gated acceptance (the JWT signature does), an invalid one never caused rejection, and the result was recorded nowhere, so the common case (a node registering because it has no CA cert yet) paid two TLS handshakes for nothing. Fetch once without transport verification: authenticity comes from the Ed25519 proof signature, host binding from the pinned, vetted IP, and redirects stay refused. Spec operator note updated to match. --- acme/ownership.go | 84 ++++++++++++++++------------------------- acme/ownership_test.go | 6 +-- docs/registration-v2.md | 9 +++-- 3 files changed, 41 insertions(+), 58 deletions(-) diff --git a/acme/ownership.go b/acme/ownership.go index c7018c0..1c0a809 100644 --- a/acme/ownership.go +++ b/acme/ownership.go @@ -165,62 +165,44 @@ func (c *acmeWriter) resolvePinnedIP(ctx context.Context, host string) (netip.Ad // 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. -// For https it first tries WebPKI verification (strong host binding); if that -// fails (e.g. the node has no valid cert yet) it retries without verification, -// where host binding rests on the IP pin and key binding on the signature. +// 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) - do := func(insecure bool) ([]byte, error) { - 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: insecure}, - } - 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 + 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 } - - if o.Scheme == "https" { - body, err := do(false) - if err == nil { - return body, nil - } - // Retry without verification only when the cert itself failed to verify - // (the node has no valid CA cert yet). Do not retry on a refused - // connection, timeout, or non-200, which would just double the work. - var certErr *tls.CertificateVerificationError - if errors.As(err, &certErr) { - return do(true) - } + resp, err := httpClient.Do(req) + if err != nil { return nil, err } - return do(true) + 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 index f28b88c..81cc494 100644 --- a/acme/ownership_test.go +++ b/acme/ownership_test.go @@ -61,10 +61,10 @@ func TestHTTPOwnershipVerify(t *testing.T) { }) } -func TestHTTPOwnershipTLSFallback(t *testing.T) { +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 must fall back from - // WebPKI to the pinned-IP + signature path. + // 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() diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 28d8bc1..b841ef8 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -208,10 +208,11 @@ Notes for operators of the endpoint: the HTTP tier. A static file server or a CDN can serve the token. - The endpoint MUST answer on port 80 or 443 on the public forge instance. - The forge MUST pin the connection to the endpoint's resolved public IP, MUST - refuse a non-public target, and MUST NOT follow redirects. It verifies TLS - against a real CA cert when one is present, and otherwise falls back to - trusting the signature and the IP (a node registering because it has no cert - yet is the common case). + 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 From a7d36855f062de6fb2837bdbec3be903fab51290 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 14:12:36 +0200 Subject: [PATCH 15/27] refactor: remove dead clock-skew allowance from v2 The "created older than 7 minutes" rule could never reject anything on its own: expires must be present, at most created+300s, and not passed, so any created old enough to trip the rule already comes with an expired signature. The 2-minute skew grace was just as dead: a slow clock shifts created and expires by the same amount. Delete MaxClockSkew and spell out the real policy in the spec: created at most 30s in the future, expires not passed, and expires-created at most 300s. The verifier keeps a created age bound only because the library needs one when verifyCreated is on. --- acme/antiabuse.go | 10 +++++----- acme/verify_v2.go | 7 ++++++- docs/registration-v2.md | 10 ++++++---- internal/httpsig/profile.go | 8 +++----- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/acme/antiabuse.go b/acme/antiabuse.go index c2f1a6c..44ded33 100644 --- a/acme/antiabuse.go +++ b/acme/antiabuse.go @@ -14,9 +14,9 @@ import ( "github.com/libp2p/go-libp2p/core/peer" ) -// nonceTTL is how long a used nonce is remembered. It must exceed the maximum -// signature lifetime plus clock skew on both sides so a nonce cannot expire from -// the store while its signature is still valid. +// nonceTTL is how long a used nonce is remembered. It must outlive the longest +// window in which a signature can still verify: the maximum signature lifetime +// plus the allowed forward drift of created. const nonceTTL = 20 * time.Minute // errReplay signals a nonce that has already been used. @@ -26,8 +26,8 @@ var errReplay = errors.New("nonce already used") // is intentionally not done here; it belongs on the fronting reverse proxy, CDN, // or load balancer. It is idempotent and safe to call from OnStartup and tests. func (c *acmeWriter) initAntiAbuse() { - if min := httpsig.MaxSignatureLifetime + 2*httpsig.MaxClockSkew; nonceTTL <= min { - panic(fmt.Sprintf("nonceTTL (%s) must exceed max signature lifetime + 2x skew (%s)", nonceTTL, min)) + if min := httpsig.MaxSignatureLifetime + httpsig.MaxForwardDrift; nonceTTL <= min { + panic(fmt.Sprintf("nonceTTL (%s) must exceed max signature lifetime + forward drift (%s)", nonceTTL, min)) } if c.nonces == nil && c.Datastore != nil { c.nonces = newNonceStore(c.Datastore, nonceTTL) diff --git a/acme/verify_v2.go b/acme/verify_v2.go index 2c1b7f7..3d98046 100644 --- a/acme/verify_v2.go +++ b/acme/verify_v2.go @@ -79,10 +79,15 @@ func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, // 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.MaxClockSkew). + SetNotOlderThan(httpsig.MaxSignatureLifetime + httpsig.MaxForwardDrift). SetRejectExpired(true). SetAllowedTags([]string{httpsig.RegistrationTag}) verifier, err := httpsign.NewEd25519Verifier(ed25519.PublicKey(raw), cfg, httpsign.Headers(httpsig.RegistrationComponents...)) diff --git a/docs/registration-v2.md b/docs/registration-v2.md index b841ef8..2fe15a1 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -92,10 +92,12 @@ 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, a -`created` older than 7 minutes (the 5-minute maximum lifetime plus 2 minutes of -allowance for a slow client clock), and an `expires` that has already passed. A -client with a fast clock SHOULD NOT sign `created` far ahead of real time. +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 diff --git a/internal/httpsig/profile.go b/internal/httpsig/profile.go index 8482a48..8bc2b1d 100644 --- a/internal/httpsig/profile.go +++ b/internal/httpsig/profile.go @@ -17,12 +17,10 @@ const ( // RFC 9421 use. RegistrationTag = "p2p-forge-reg" - // MaxSignatureLifetime bounds expires-created. + // 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 - // MaxClockSkew extends the accepted age of `created` beyond - // MaxSignatureLifetime, tolerating a slow client clock. `expires` gets no - // such grace: a signature past its own deadline is rejected outright. - MaxClockSkew = 2 * time.Minute // MaxForwardDrift is how far in the future `created` may be. MaxForwardDrift = 30 * time.Second // MinNonceLen is the minimum accepted nonce length in base64url From 7d48e4098c45cd20cafab9e2b13644db2f7ae8ad Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 14:22:16 +0200 Subject: [PATCH 16/27] refactor: drop the GET /v2 descriptor The endpoint served a JSON summary of the signing profile, but the spec never defined its fields, half the values were prose ("did:key (Ed25519)"), and nothing consumed it: the client detects v2 support by POSTing and treating 404/405 as unsupported. A discovery endpoint can return once there is more than one key type to discover, with a defined schema and machine-readable values. key-types.md now points at the malformed-signature error instead of descriptor-based discovery. --- acme/writer.go | 1 - acme/writer_v2.go | 28 ---------------------------- acme/writer_v2_test.go | 11 ----------- docs/key-types.md | 4 ++-- docs/registration-v2.md | 1 - 5 files changed, 2 insertions(+), 43 deletions(-) diff --git a/acme/writer.go b/acme/writer.go index 4c704ed..c2e27be 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -227,7 +227,6 @@ func (c *acmeWriter) OnStartup() error { // v2 registration API: RFC 9421-signed, no libp2p PeerID-auth handshake. mux.Handle("POST "+registrationV2ApiPath, std.Handler(registrationV2ApiPath, httpMetricsMiddleware, http.HandlerFunc(c.handleV2Challenge))) - mux.Handle("GET "+profileV2ApiPath, std.Handler(profileV2ApiPath, httpMetricsMiddleware, http.HandlerFunc(c.handleV2Profile))) mux.HandleFunc("GET "+healthV2ApiPath, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) diff --git a/acme/writer_v2.go b/acme/writer_v2.go index ee373a2..b0416c1 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -12,7 +12,6 @@ import ( "github.com/ipfs/go-datastore" "github.com/ipshipyard/p2p-forge/client" - "github.com/ipshipyard/p2p-forge/internal/httpsig" "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multibase" ) @@ -21,7 +20,6 @@ import ( const ( registrationV2ApiPath = "/v2/_acme-challenge" healthV2ApiPath = "/v2/health" - profileV2ApiPath = "/v2" ) // maxV2BodySize bounds the registration request body. @@ -130,21 +128,6 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { }) } -// handleV2Profile serves a static descriptor so a generic signer can discover -// the required covered components and limits without reading source. -func (c *acmeWriter) handleV2Profile(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, v2Profile{ - Endpoint: registrationV2ApiPath, - KeyTypes: []string{"did:key (Ed25519)"}, - CoveredComponents: []string{"@method", "@authority", "@path", "content-type", "content-digest"}, - SignatureParams: []string{"created", "expires", "nonce", "keyid", "tag"}, - SignatureTag: httpsig.RegistrationTag, - MaxBodyBytes: maxV2BodySize, - MaxSignatureAgeS: int(httpsig.MaxSignatureLifetime / time.Second), - ContentDigest: "sha-256 (RFC 9530), required, covered by the signature", - }) -} - type v2Response struct { // DID is the did:key that registered (libp2p-agnostic; no raw peerid). DID string `json:"did"` @@ -158,17 +141,6 @@ type v2Verification struct { Mode string `json:"mode"` } -type v2Profile struct { - Endpoint string `json:"endpoint"` - KeyTypes []string `json:"keyTypes"` - CoveredComponents []string `json:"coveredComponents"` - SignatureParams []string `json:"signatureParams"` - SignatureTag string `json:"signatureTag"` - MaxBodyBytes int `json:"maxBodyBytes"` - MaxSignatureAgeS int `json:"maxSignatureAgeSeconds"` - ContentDigest string `json:"contentDigest"` -} - // decodeV2Body parses the registration body, rejecting unknown fields and any // trailing data. func decodeV2Body(body []byte) (*requestBody, error) { diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index ccc7ff4..dbc98b7 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -302,14 +302,3 @@ func TestV2ChallengeHandlerRejects(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) }) } - -func TestV2ProfileHandler(t *testing.T) { - rec := httptest.NewRecorder() - newTestWriter().handleV2Profile(rec, httptest.NewRequest(http.MethodGet, "/v2", nil)) - require.Equal(t, http.StatusOK, rec.Code) - - var p v2Profile - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p)) - require.Equal(t, httpsig.RegistrationTag, p.SignatureTag) - require.Equal(t, registrationV2ApiPath, p.Endpoint) -} diff --git a/docs/key-types.md b/docs/key-types.md index e33ebf6..5c3d220 100644 --- a/docs/key-types.md +++ b/docs/key-types.md @@ -15,8 +15,8 @@ registry rather than invented here: self-describing: the key's type is a [multicodec](https://github.com/multiformats/multicodec) prefix on the raw public key. Adding a type means accepting its multicodec when decoding the - `did:key`. Old clients are unaffected, and the `GET /v2` descriptor advertises - which key types an instance accepts, so a client can discover support. + `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 diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 2fe15a1..efa654c 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -45,7 +45,6 @@ name and the cert name, which `/v2` shares with `/v1`. | --- | --- | --- | | `POST` | `/v2/_acme-challenge` | Set the DNS-01 TXT value for the peer derived from the signing key. | | `GET` | `/v2/health` | Liveness. Always `204`. | -| `GET` | `/v2` | Static JSON descriptor of this profile (accepted keys, covered components, limits). | ## Request signing From c3f4d58f8b9ae32704f8359c7232f07d9271addb Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 14:28:39 +0200 Subject: [PATCH 17/27] refactor(acme): drop the v2 single-use nonce store A replayed v2 request cannot do anything new: the signature binds the body, so a replay re-verifies the same addresses and re-writes the same TXT value. An attacker gains more by minting a free Ed25519 key and sending fresh requests, and the defenses for that (destination vetting, timeouts, rate limiting on the fronting proxy) cover replays too. The store also could not keep its promise. reserve() was a non-atomic Has+Put behind one process-wide mutex: every registration on an instance queued behind two datastore round trips, while two instances (or one, on eventually-consistent DynamoDB reads) could still both accept the same nonce. When the store was nil, the check silently vanished. Replay is now bounded by the expires window (at most 300s plus 30s of forward drift). The nonce stays a required signature parameter: it keeps every signature unique, and a server MAY enforce single-use later without any client change. The 409 and nonce-store-error responses are gone from the spec and the code. --- acme/antiabuse.go | 72 ----------------------------------------- acme/antiabuse_test.go | 58 --------------------------------- acme/verify_v2.go | 5 +-- acme/writer.go | 4 --- acme/writer_v2.go | 13 -------- acme/writer_v2_test.go | 28 +++++++++++++++- docs/registration-v2.md | 12 ++++--- 7 files changed, 37 insertions(+), 155 deletions(-) delete mode 100644 acme/antiabuse.go delete mode 100644 acme/antiabuse_test.go diff --git a/acme/antiabuse.go b/acme/antiabuse.go deleted file mode 100644 index 44ded33..0000000 --- a/acme/antiabuse.go +++ /dev/null @@ -1,72 +0,0 @@ -package acme - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "sync" - "time" - - "github.com/ipfs/go-datastore" - "github.com/ipshipyard/p2p-forge/internal/httpsig" - "github.com/libp2p/go-libp2p/core/peer" -) - -// nonceTTL is how long a used nonce is remembered. It must outlive the longest -// window in which a signature can still verify: the maximum signature lifetime -// plus the allowed forward drift of created. -const nonceTTL = 20 * time.Minute - -// errReplay signals a nonce that has already been used. -var errReplay = errors.New("nonce already used") - -// initAntiAbuse constructs the per-instance nonce store. Request rate limiting -// is intentionally not done here; it belongs on the fronting reverse proxy, CDN, -// or load balancer. It is idempotent and safe to call from OnStartup and tests. -func (c *acmeWriter) initAntiAbuse() { - if min := httpsig.MaxSignatureLifetime + httpsig.MaxForwardDrift; nonceTTL <= min { - panic(fmt.Sprintf("nonceTTL (%s) must exceed max signature lifetime + forward drift (%s)", nonceTTL, min)) - } - if c.nonces == nil && c.Datastore != nil { - c.nonces = newNonceStore(c.Datastore, nonceTTL) - } -} - -// nonceStore records used nonces so a captured signed request cannot be -// replayed within its validity window. Reservation is atomic within one -// instance via the mutex; across load-balanced instances two truly simultaneous -// replays can both pass, which is low-harm because the resulting write is -// idempotent. It fails closed if the datastore is unavailable. -type nonceStore struct { - ds datastore.TTLDatastore - ttl time.Duration - mu sync.Mutex -} - -func newNonceStore(ds datastore.TTLDatastore, ttl time.Duration) *nonceStore { - return &nonceStore{ds: ds, ttl: ttl} -} - -// reserve records (peerID, nonce) as used, returning errReplay if it was already -// present. The peer.ID is a server-derived internal key, never on the wire. -func (n *nonceStore) reserve(ctx context.Context, peerID peer.ID, nonce string) error { - sum := sha256.Sum256([]byte(nonce)) - key := datastore.NewKey("/v2/nonce/" + peerID.String() + "/" + hex.EncodeToString(sum[:])) - - n.mu.Lock() - defer n.mu.Unlock() - - has, err := n.ds.Has(ctx, key) - if err != nil { - return fmt.Errorf("nonce store unavailable: %w", err) - } - if has { - return errReplay - } - if err := n.ds.PutWithTTL(ctx, key, []byte{}, n.ttl); err != nil { - return fmt.Errorf("nonce store write failed: %w", err) - } - return nil -} diff --git a/acme/antiabuse_test.go b/acme/antiabuse_test.go deleted file mode 100644 index 148d71b..0000000 --- a/acme/antiabuse_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package acme - -import ( - "bytes" - "encoding/base64" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/ipfs/go-datastore" - "github.com/libp2p/go-libp2p/core/peer" - "github.com/stretchr/testify/require" -) - -func TestNonceStoreReplay(t *testing.T) { - ns := newNonceStore(ttlDatastore{datastore.NewMapDatastore()}, time.Minute) - _, priv := newRegistrantHost(t) - pid, err := peer.IDFromPublicKey(priv.GetPublic()) - require.NoError(t, err) - - require.NoError(t, ns.reserve(t.Context(), pid, "nonce-abc")) - require.ErrorIs(t, ns.reserve(t.Context(), pid, "nonce-abc"), errReplay) - require.NoError(t, ns.reserve(t.Context(), pid, "nonce-xyz"), "different nonce is fresh") -} - -func TestV2NonceReplayRejected(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 := readAndRestore(signed) - require.NoError(t, err) - header := signed.Header.Clone() - - replay := 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, replay().Code, "first submission succeeds") - require.Equal(t, http.StatusConflict, replay().Code, "replay of the same nonce is rejected") -} - -// readAndRestore reads a request body and refills it so the request stays usable. -func readAndRestore(r *http.Request) ([]byte, error) { - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(r.Body); err != nil { - return nil, err - } - return buf.Bytes(), nil -} diff --git a/acme/verify_v2.go b/acme/verify_v2.go index 3d98046..b9f39ea 100644 --- a/acme/verify_v2.go +++ b/acme/verify_v2.go @@ -17,7 +17,6 @@ import ( type v2Verified struct { peerID peer.ID keyID string // the did:key that signed - nonce string } // errMalformed marks a request that does not conform to the /v2 signing @@ -39,6 +38,8 @@ func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, if details.KeyID == nil { return nil, fmt.Errorf("%w: signature is missing a keyid", 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) } @@ -107,5 +108,5 @@ func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, if err != nil { return nil, fmt.Errorf("deriving peer ID: %w", err) } - return &v2Verified{peerID: peerID, keyID: *details.KeyID, nonce: *details.Nonce}, nil + return &v2Verified{peerID: peerID, keyID: *details.KeyID}, nil } diff --git a/acme/writer.go b/acme/writer.go index c2e27be..346f2fc 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -62,8 +62,6 @@ type acmeWriter struct { Datastore datastore.TTLDatastore - nonces *nonceStore - ln net.Listener nlSetup bool closeCertMgr func() @@ -111,8 +109,6 @@ func (c *acmeWriter) OnStartup() error { c.ln = ln c.nlSetup = true - c.initAntiAbuse() - // server side secret key and peerID not particularly relevant, so we can generate new ones as needed sk, _, err := crypto.GenerateEd25519Key(rand.Reader) if err != nil { diff --git a/acme/writer_v2.go b/acme/writer_v2.go index b0416c1..e394bb8 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -76,19 +76,6 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { } peerID := verified.peerID - // Single-use nonce: reject a replayed signed request before the dialback. - if c.nonces != nil { - if err := c.nonces.reserve(r.Context(), peerID, verified.nonce); err != nil { - if errors.Is(err, errReplay) { - writeProblem(w, http.StatusConflict, "nonce-replayed", "this request has already been submitted") - } else { - writeProblem(w, http.StatusInternalServerError, "nonce-store-error", "could not verify request freshness") - log.Errorf("v2: nonce store error for %s: %v", peerID, err) - } - return - } - } - typedBody, err := decodeV2Body(body) if err != nil { writeProblem(w, http.StatusBadRequest, "malformed-body", err.Error()) diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index dbc98b7..f5e17a7 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -130,10 +130,36 @@ func newTestWriter() *acmeWriter { // Tests dial loopback hosts, which destination-IP vetting would reject. AllowPrivateAddrs: true, } - c.initAntiAbuse() 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) diff --git a/docs/registration-v2.md b/docs/registration-v2.md index efa654c..6423549 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -81,7 +81,7 @@ with these signature parameters: | --- | --- | | `created` | Unix seconds when signed. | | `expires` | Unix seconds when the signature stops being valid. `expires - created` MUST be `<= 300`. | -| `nonce` | Random, at least 128 bits, base64url. MUST be single-use (see replay below). | +| `nonce` | Random, at least 128 bits, base64url, fresh for every signature. | | `keyid` | The `did:key` above. | | `tag` | `p2p-forge-reg`. | @@ -244,10 +244,9 @@ classes apart SHOULD match on that fragment. | `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` | `forbidden` | Missing or wrong `Forge-Authorization` where the operator requires one. | | `403` | `denylisted` | The client IP or a submitted address is denylisted. | -| `409` | `nonce-replayed` | The nonce was already used (replay). | | `413` | `body-too-large` | The body exceeds 8 KiB. | | `422` | `verification-failed` | No submitted address could be verified. | -| `500` | `misconfigured`, `nonce-store-error`, `storage-error` | Server-side failure; safe to retry later. | +| `500` | `misconfigured`, `storage-error` | Server-side failure; safe to retry later. | A fronting proxy may add others, such as `429` when it rate-limits. @@ -255,8 +254,11 @@ A fronting proxy may add others, such as `429` when it rate-limits. - **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 MUST treat each `nonce` as single-use and reject a - reused one, so a captured request cannot be replayed within its window. +- **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 From 3c1ca555113f3a75726188d04424f3edfc4c3afd Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 14:47:32 +0200 Subject: [PATCH 18/27] docs: keep the forge auth token out of the v2 spec The optional Forge-Authorization gate is a p2p-forge extra for limited rollouts and test instances, not protocol, so the v2 spec no longer defines it: the 403 forbidden row is gone and the error table notes that access control outside the spec may add statuses. The code keeps the gate. Its godoc now says exactly what it is, and its 403 response uses the generic about:blank problem type instead of pointing at a spec anchor that defines nothing. --- acme/writer_v2.go | 18 ++++++++++++++---- acme/writer_v2_test.go | 25 +++++++++++++------------ client/defaults.go | 6 ++++-- docs/registration-v2.md | 5 +++-- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/acme/writer_v2.go b/acme/writer_v2.go index e394bb8..8ea9ac8 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -44,9 +44,13 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } - // Cheapest gate first: optional shared-secret access token. + // 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, "forbidden", fmt.Sprintf("missing or invalid %s header", client.ForgeAuthHeader)) + writeProblem(w, http.StatusForbidden, "", fmt.Sprintf("missing or invalid %s header", client.ForgeAuthHeader)) return } @@ -174,12 +178,18 @@ func writeJSON(w http.ResponseWriter, status int, v any) { // 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. +// 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": problemTypeBase + problemType, + "type": typeURI, "title": http.StatusText(status), "status": status, "detail": detail, diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index f5e17a7..a0b4c75 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -267,18 +267,19 @@ func TestV2ChallengeHandlerRejects(t *testing.T) { c := newTestWriter() c.forgeAuthKey = "test-secret" - missing := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", nil) - missing.Host = v2TestDomain - rec := httptest.NewRecorder() - c.handleV2Challenge(rec, missing) - require.Equal(t, http.StatusForbidden, rec.Code, "missing token") - - wrong := httptest.NewRequest(http.MethodPost, "https://"+v2TestDomain+"/v2/_acme-challenge", nil) - wrong.Host = v2TestDomain - wrong.Header.Set(client.ForgeAuthHeader, "not-the-secret") - rec = httptest.NewRecorder() - c.handleV2Challenge(rec, wrong) - require.Equal(t, http.StatusForbidden, rec.Code, "wrong token") + 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) { 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/docs/registration-v2.md b/docs/registration-v2.md index 6423549..7fddcf4 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -242,13 +242,14 @@ classes apart SHOULD match on that fragment. | `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 bad `did:key`, a nonce under 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` | `forbidden` | Missing or wrong `Forge-Authorization` where the operator requires one. | | `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 may add others, such as `429` when it rate-limits. +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 From 0bc9f27bb774c7eb2e51f400d47e86d0698ffc37 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 15:18:54 +0200 Subject: [PATCH 19/27] fix: enforce the closed v2 signature grammar The spec promised a closed grammar the verifier did not have: the library checks that required components are covered, but accepts extra components, any order, any label, unknown parameters, any alg value, and any nonce shape. A request the spec calls invalid would register fine, and a second implementation written from the spec would diverge from this one. The server now rejects, before any cryptography runs: - more than one Signature-Input or Signature header, more than one signature, or a label other than sig1 - covered components that are not exactly the profile list, in order, with no per-component parameters - an alg other than ed25519 (the key in keyid decides the algorithm) and any signature parameter outside the profile - a nonce that is not unpadded base64url of at least 128 bits - a Content-Type other than application/json The client signs without alg, matching the spec example. The spec now states each rule where it was silent, and the error table lists the new rejections. --- acme/verify_v2.go | 71 +++++++++++++++++++++++++++++++- acme/writer_v2.go | 8 +++- acme/writer_v2_test.go | 80 ++++++++++++++++++++++++++++++++++++- client/challenge_v2.go | 5 ++- docs/registration-v2.md | 30 ++++++++++---- go.mod | 2 +- internal/httpsig/profile.go | 7 ++-- 7 files changed, 184 insertions(+), 19 deletions(-) diff --git a/acme/verify_v2.go b/acme/verify_v2.go index b9f39ea..8629028 100644 --- a/acme/verify_v2.go +++ b/acme/verify_v2.go @@ -3,11 +3,13 @@ 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" @@ -30,6 +32,9 @@ var errMalformed = errors.New("malformed request") // 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 { @@ -38,13 +43,24 @@ func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, 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) } - if len(*details.Nonce) < httpsig.MinNonceLen { - return nil, fmt.Errorf("%w: nonce is shorter than %d base64url characters", errMalformed, httpsig.MinNonceLen) + 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 @@ -110,3 +126,54 @@ func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, } return &v2Verified{peerID: peerID, keyID: *details.KeyID}, 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_v2.go b/acme/writer_v2.go index 8ea9ac8..717c4a3 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "time" @@ -43,7 +44,6 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { 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 @@ -54,6 +54,12 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { 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 diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index a0b4c75..7a8bbe0 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -64,6 +65,7 @@ type v2SignOpts struct { 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 { @@ -105,7 +107,11 @@ func signedV2RequestOpts(t *testing.T, priv crypto.PrivKey, value string, addrs } cfg = cfg.SetExpires(time.Now().Add(expiresIn).Unix()) } - signer, err := httpsign.NewEd25519Signer(ed25519.PrivateKey(raw), cfg, httpsign.Headers(httpsig.RegistrationComponents...)) + 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) @@ -230,7 +236,7 @@ func TestV2ChallengeHandlerRejects(t *testing.T) { t.Run("nonce below 128 bits", func(t *testing.T) { h, priv := newRegistrantHost(t) - // 12 bytes encode to 16 base64url characters, under the 22 minimum. + // 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() @@ -238,6 +244,76 @@ func TestV2ChallengeHandlerRejects(t *testing.T) { 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 diff --git a/client/challenge_v2.go b/client/challenge_v2.go index ea78c50..86ca57c 100644 --- a/client/challenge_v2.go +++ b/client/challenge_v2.go @@ -152,7 +152,10 @@ func signV2Request(req *http.Request, privKey crypto.PrivKey, body []byte) error } 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). @@ -173,7 +176,7 @@ func signV2Request(req *http.Request, privKey crypto.PrivKey, body []byte) error // generateNonce returns a fresh base64url nonce with 128 bits of entropy. func generateNonce() (string, error) { - b := make([]byte, 16) + b := make([]byte, httpsig.MinNonceBytes) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generating nonce: %w", err) } diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 7fddcf4..3676443 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -81,10 +81,17 @@ with these signature parameters: | --- | --- | | `created` | Unix seconds when signed. | | `expires` | Unix seconds when the signature stops being valid. `expires - created` MUST be `<= 300`. | -| `nonce` | Random, at least 128 bits, base64url, fresh for every signature. | +| `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 @@ -113,15 +120,20 @@ trailing newline. For a POST to `registration.libp2p.direct` it looks like: "@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 use the label `sig1`: +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 signature is a raw -Ed25519 signature over the UTF-8 bytes of the base. +`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 @@ -132,8 +144,10 @@ Ed25519 signature over the UTF-8 bytes of the base. } ``` -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). +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 @@ -238,9 +252,9 @@ classes apart SHOULD match on that fragment. | Status | `type` fragment | When | | --- | --- | --- | | `400` | `unexpected-query` | The request carries a query string. | -| `400` | `malformed-body` | The body is not the JSON object above, has unknown fields, or has trailing data. | +| `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 bad `did:key`, a nonce under 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. | +| `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. | diff --git a/go.mod b/go.mod index 11c3b4e..2c5c1f0 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ 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 @@ -64,7 +65,6 @@ 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 diff --git a/internal/httpsig/profile.go b/internal/httpsig/profile.go index 8bc2b1d..2f97a06 100644 --- a/internal/httpsig/profile.go +++ b/internal/httpsig/profile.go @@ -23,10 +23,9 @@ const ( MaxSignatureLifetime = 5 * time.Minute // MaxForwardDrift is how far in the future `created` may be. MaxForwardDrift = 30 * time.Second - // MinNonceLen is the minimum accepted nonce length in base64url - // characters: 22 characters is the shortest encoding of the required 128 - // bits of entropy. - MinNonceLen = 22 + // 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 From 77a3c8f6950103c2e533c788946ae5a2289f4b46 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 15:54:21 +0200 Subject: [PATCH 20/27] fix: harden the http-ownership proof Gaps between what the spec promises and what ran, closed on the strict side, plus one restriction dropped as wrong: - the JWT now carries typ "p2p-forge-ownership+jwt" and the verifier requires it (RFC 8725 explicit typing), so no other JWT signed by the same identity key can pass as a proof - the proof endpoint may listen on any port: requiring 80 or 443 excluded exactly the NATed nodes AutoTLS exists for (UPnP or router-forwarded ports), while the libp2p dialback already accepted arbitrary ports. The public IP stays vetted, pinned, and denylistable; the port never mattered for abuse control - Sign counts exp from the backdated iat, so exp-iat never exceeds the requested ttl, and Verify enforces the 14-day cap exactly instead of quietly widening it - the fetch pins one vetted resolved IP per address family and tries each, so a single stale AAAA no longer sinks a proof that a working A record could serve - address partitioning treats schemes case-insensitively, so HTTPS://gw.example is an http URL, not a broken multiaddr The spec now defines the origin string as an algorithm with examples (citing RFC 5952 and RFC 6454, stating the deviation), documents the clock-skew allowances, shows http alongside https in the proof URL template, and notes the well-known suffix may be registered with IANA later if the API sees adoption. The registration key is decoded once per request instead of once per submitted URL. --- acme/ownership.go | 101 +++++++++++++++------------ acme/ownership_test.go | 27 +++++-- acme/verify_v2.go | 5 +- acme/writer_v2.go | 2 +- docs/registration-v2.md | 60 +++++++++++----- internal/ownership/ownership.go | 24 +++++-- internal/ownership/ownership_test.go | 47 +++++++++++++ 7 files changed, 189 insertions(+), 77 deletions(-) diff --git a/acme/ownership.go b/acme/ownership.go index 1c0a809..94eaffd 100644 --- a/acme/ownership.go +++ b/acme/ownership.go @@ -15,9 +15,7 @@ import ( "github.com/ipshipyard/p2p-forge/client" "github.com/ipshipyard/p2p-forge/denylist" - "github.com/ipshipyard/p2p-forge/internal/httpsig" "github.com/ipshipyard/p2p-forge/internal/ownership" - "github.com/libp2p/go-libp2p/core/peer" ) const ( @@ -35,7 +33,7 @@ const ( // 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, keyID string, peerID peer.ID, addrs []string, userAgent string) (string, error) { +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) @@ -46,14 +44,14 @@ func (c *acmeWriter) verifyReachable(ctx context.Context, keyID string, peerID p var lastErr error if len(httpURLs) > 0 { - if err := c.verifyHTTPOwnership(ctx, keyID, httpURLs); err == nil { + 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, peerID, libp2pAddrs, userAgent); err == nil { + if err := c.testAddresses(ctx, v.peerID, libp2pAddrs, userAgent); err == nil { return "libp2p-dialback", nil } else { lastErr = err @@ -67,9 +65,11 @@ func (c *acmeWriter) verifyReachable(ctx context.Context, keyID string, peerID p // 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 { - if strings.HasPrefix(a, "http://") || strings.HasPrefix(a, "https://") { + lower := strings.ToLower(a) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { httpURLs = append(httpURLs, a) } else { libp2pAddrs = append(libp2pAddrs, a) @@ -79,8 +79,8 @@ func partitionAddrs(addrs []string) (httpURLs, libp2pAddrs []string) { } // verifyHTTPOwnership tries each submitted http endpoint until one serves a -// valid ownership proof signed by the registration key. -func (c *acmeWriter) verifyHTTPOwnership(ctx context.Context, keyID string, urls []string) error { +// valid ownership proof signed by the registration key pub. +func (c *acmeWriter) verifyHTTPOwnership(ctx context.Context, pub ed25519.PublicKey, keyID string, urls []string) error { if !c.AllowPrivateAddrs && len(urls) > maxOwnershipURLs { return fmt.Errorf("too many http addresses (%d > %d)", len(urls), maxOwnershipURLs) } @@ -91,7 +91,7 @@ func (c *acmeWriter) verifyHTTPOwnership(ctx context.Context, keyID string, urls lastErr = err continue } - if err := c.fetchAndVerifyOwnership(ctx, keyID, o); err != nil { + if err := c.fetchAndVerifyOwnership(ctx, pub, keyID, o); err != nil { lastErr = err continue } @@ -103,64 +103,73 @@ func (c *acmeWriter) verifyHTTPOwnership(ctx context.Context, keyID string, urls return lastErr } -func (c *acmeWriter) fetchAndVerifyOwnership(ctx context.Context, keyID string, o client.HTTPOrigin) error { - if !c.AllowPrivateAddrs && o.Port != "80" && o.Port != "443" { - return fmt.Errorf("ownership fetch port %s not allowed", o.Port) - } - - ip, err := c.resolvePinnedIP(ctx, o.Host) +// 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 } - if mgr := denylist.GetManager(); mgr != nil { - if denied, res := mgr.Check(ip); denied { - return fmt.Errorf("endpoint IP %s blocked by %s", ip, res.Name) + 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()) } - - // The proof must verify under the registration key, never a key the proof - // itself names. - regPub, err := httpsig.DecodeDIDKey(keyID) - if err != nil { - return err - } - raw, err := regPub.Raw() - if err != nil { - return fmt.Errorf("reading registration key: %w", err) - } - - proof, err := fetchOwnershipProof(ctx, o, ip, keyID) - if err != nil { - return err - } - return ownership.Verify(string(proof), ed25519.PublicKey(raw), o.Origin, time.Now()) + return lastErr } -// resolvePinnedIP resolves host to a single vettable public IP to pin the fetch -// to. A literal IP is used directly. Vetting is skipped when AllowPrivateAddrs. -func (c *acmeWriter) resolvePinnedIP(ctx context.Context, host string) (netip.Addr, error) { +// 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 netip.Addr{}, err + return nil, err } } - return ip.Unmap(), nil + return []netip.Addr{ip.Unmap()}, nil } ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) if err != nil { - return netip.Addr{}, fmt.Errorf("resolving %s: %w", host, err) + 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 { - return ip, nil + if !c.AllowPrivateAddrs && vetDestIP(ip) != nil { + continue } - if vetDestIP(ip) == nil { - return ip, nil + 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 netip.Addr{}, fmt.Errorf("no public IP for %s", host) + return picked, nil } // fetchOwnershipProof GETs the well-known proof, pinning the connection to ip so diff --git a/acme/ownership_test.go b/acme/ownership_test.go index 81cc494..8665ed4 100644 --- a/acme/ownership_test.go +++ b/acme/ownership_test.go @@ -2,6 +2,7 @@ package acme import ( "bytes" + "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" @@ -16,6 +17,14 @@ import ( "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) { @@ -41,7 +50,7 @@ func TestHTTPOwnershipVerify(t *testing.T) { c := newTestWriter() // AllowPrivateAddrs=true: loopback + non-standard port t.Run("valid proof verifies", func(t *testing.T) { - require.NoError(t, c.verifyHTTPOwnership(t.Context(), did, []string{srv.URL})) + require.NoError(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{srv.URL})) }) t.Run("wrong registration key fails", func(t *testing.T) { @@ -49,15 +58,21 @@ func TestHTTPOwnershipVerify(t *testing.T) { require.NoError(t, err) otherDID, err := httpsig.EncodeDIDKey(other.GetPublic()) require.NoError(t, err) - // The endpoint serves priv's proof; verifying it as otherDID must fail + // 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(), otherDID, []string{srv.URL})) + 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(), did, []string{bare.URL})) + 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})) }) } @@ -77,7 +92,7 @@ func TestHTTPOwnershipSelfSignedTLS(t *testing.T) { require.NoError(t, err) c := newTestWriter() - require.NoError(t, c.verifyHTTPOwnership(t.Context(), did, []string{srv.URL})) + require.NoError(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{srv.URL})) } func TestV2HTTPOwnershipEndToEnd(t *testing.T) { @@ -159,5 +174,5 @@ func TestHTTPOwnershipIPv6Literal(t *testing.T) { require.Contains(t, o.Origin, "[::1]") c := newTestWriter() - require.NoError(t, c.verifyHTTPOwnership(t.Context(), did, []string{srv.URL})) + require.NoError(t, c.verifyHTTPOwnership(t.Context(), ed25519Pub(t, priv), did, []string{srv.URL})) } diff --git a/acme/verify_v2.go b/acme/verify_v2.go index 8629028..345ba9c 100644 --- a/acme/verify_v2.go +++ b/acme/verify_v2.go @@ -18,7 +18,8 @@ import ( // v2Verified is the authenticated result of a valid registration request. type v2Verified struct { peerID peer.ID - keyID string // the did:key that signed + 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 @@ -124,7 +125,7 @@ func verifyV2Request(r *http.Request, body []byte, domain string) (*v2Verified, if err != nil { return nil, fmt.Errorf("deriving peer ID: %w", err) } - return &v2Verified{peerID: peerID, keyID: *details.KeyID}, nil + return &v2Verified{peerID: peerID, keyID: *details.KeyID, pub: ed25519.PublicKey(raw)}, nil } // enforceV2Envelope rejects signature headers that stray from the closed /v2 diff --git a/acme/writer_v2.go b/acme/writer_v2.go index 717c4a3..b559504 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -104,7 +104,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { // 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.keyID, peerID, typedBody.Addresses, r.Header.Get("User-Agent")) + 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") diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 3676443..79d7a0e 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -187,30 +187,54 @@ The node MUST serve, at the key-scoped path below, a compact EdDSA JWT origin: ``` -GET https://[:]/.well-known/p2p-forge/ +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 use -`alg: EdDSA`, and the payload carries these claims: +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. | +| `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. | - -`origin` uses the lowercase host and an explicit port. An IP-literal host is in -its canonical textual form, and an IPv6 literal is bracketed, for example -`https://[2001:db8::1]:443`. +| `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 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. Freshness comes from the forge fetching the proof live, so a stale proof -at a dead node does not verify. +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 @@ -221,9 +245,13 @@ 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 MUST answer on port 80 or 443 on the public forge instance. -- The forge MUST pin the connection to the endpoint's resolved public IP, MUST - refuse a non-public target, and MUST NOT follow redirects. It does not +- 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 diff --git a/internal/ownership/ownership.go b/internal/ownership/ownership.go index c4f5c6a..cfb1723 100644 --- a/internal/ownership/ownership.go +++ b/internal/ownership/ownership.go @@ -22,6 +22,11 @@ const ( 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 { @@ -30,18 +35,21 @@ type claims struct { } // Sign returns a compact EdDSA JWT proving priv controls origin, valid for ttl -// (clamped to MaxWindow). +// (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(now.Add(-backdate)), - ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + 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) @@ -61,15 +69,19 @@ func Verify(token string, pub ed25519.PublicKey, expectedOrigin string, now time jwt.WithLeeway(clockSkew), jwt.WithTimeFunc(func() time.Time { return now }), ) - if _, err := parser.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { + tok, err := parser.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { return pub, nil - }); err != 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+backdate { + if c.ExpiresAt.Sub(c.IssuedAt.Time) > MaxWindow { return fmt.Errorf("ownership proof window exceeds %s", MaxWindow) } if c.Origin != expectedOrigin { diff --git a/internal/ownership/ownership_test.go b/internal/ownership/ownership_test.go index d5b6bea..f44b052 100644 --- a/internal/ownership/ownership_test.go +++ b/internal/ownership/ownership_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" ) @@ -50,3 +51,49 @@ func TestOwnershipRejects(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") +} From 86a62bde107e44c2b6716dc5528cae36de929e58 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 16:39:42 +0200 Subject: [PATCH 21/27] fix: tighten the reachability probe guards The probe timeout only covered the dial: /dns* resolution ran unbounded, and the v1 handler calls testAddresses with no timeout of its own, so up to 32 resolutions against a tarpit nameserver could hold a request goroutine (and its libp2p host) for minutes. The timeout now wraps the whole probe, resolution included, which also closes the v1 gap without changing v1 wire behavior. The address caps now bound work instead of failing registrations: addresses past the cap (32 multiaddrs, 8 http URLs) are ignored, so a client sending 40 addresses with 3 dialable ones registers instead of being rejected outright. Both caps are now in the spec. vetDestIP gains three ranges the list missed: 192.0.0.0/24 (IETF protocol assignments, incl. DS-Lite), 192.88.99.0/24 (deprecated 6to4 relay anycast), and 100::/64 (discard-only). allow-private-addresses docs (README, spec, field godoc) now list everything the flag disables, and the spec shows the real Corefile syntax (a registration-domain argument, not a standalone option). --- README.md | 2 +- acme/dialguard.go | 44 +++++++++++++++++++++++++++-------------- acme/dialguard_test.go | 4 ++++ acme/ownership.go | 3 ++- acme/writer.go | 7 ++++--- docs/registration-v2.md | 25 ++++++++++++++--------- 6 files changed, 56 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 001a5e1..8b15e61 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ 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 destination-IP vetting, the address cap, and the dial timeout on the reachability probe. Defaults to false. Use it only for local testing or a private deployment that trusts submitted addresses; never on a public instance. + - `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. Without it, only the direct connection address is trusted; a forged `X-Forwarded-For` is ignored. 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`) diff --git a/acme/dialguard.go b/acme/dialguard.go index 32248a4..46cefd6 100644 --- a/acme/dialguard.go +++ b/acme/dialguard.go @@ -16,13 +16,15 @@ import ( manet "github.com/multiformats/go-multiaddr/net" ) -// probeTimeout bounds a single reachability probe so a slow or blackholed -// target cannot pin a request goroutine and its libp2p host. +// 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 addresses one registration may ask the -// forge to dial, limiting dial fan-out and reflection. It is enforced only when -// address vetting is on (see acmeWriter.AllowPrivateAddrs). +// 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, @@ -33,9 +35,12 @@ const maxProbeAddresses = 32 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) @@ -145,6 +150,7 @@ func resolveAndVet(ctx context.Context, resolver *madns.Resolver, addrStrs []str dialable []multiaddr.Multiaddr ips []netip.Addr seen = map[netip.Addr]struct{}{} + inputs int ) outer: for _, s := range addrStrs { @@ -155,6 +161,13 @@ outer: 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) { @@ -194,14 +207,19 @@ outer: // 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 dial -// with a timeout. +// 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) - if !c.AllowPrivateAddrs && len(addrStrs) > maxProbeAddresses { - recordPeerProbe("error", agentVersion) - return fmt.Errorf("too many addresses (%d > %d)", len(addrStrs), maxProbeAddresses) + // 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) @@ -222,11 +240,7 @@ func (c *acmeWriter) testAddresses(ctx context.Context, p peer.ID, addrStrs []st } opts := []libp2p.Option{libp2p.NoListenAddrs, libp2p.DisableRelay()} - dialCtx := ctx if !c.AllowPrivateAddrs { - var cancel context.CancelFunc - dialCtx, cancel = context.WithTimeout(ctx, probeTimeout) - defer cancel() opts = append(opts, libp2p.ConnectionGater(newIPGater(ips))) } @@ -237,7 +251,7 @@ func (c *acmeWriter) testAddresses(ctx context.Context, p peer.ID, addrStrs []st } defer h.Close() - if err := h.Connect(dialCtx, peer.AddrInfo{ID: p, Addrs: dialable}); err != nil { + 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 diff --git a/acme/dialguard_test.go b/acme/dialguard_test.go index 26d7831..75a0d8c 100644 --- a/acme/dialguard_test.go +++ b/acme/dialguard_test.go @@ -38,6 +38,10 @@ func TestVetDestIP(t *testing.T) { "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) { diff --git a/acme/ownership.go b/acme/ownership.go index 94eaffd..5265b59 100644 --- a/acme/ownership.go +++ b/acme/ownership.go @@ -81,8 +81,9 @@ func partitionAddrs(addrs []string) (httpURLs, libp2pAddrs []string) { // 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 { - return fmt.Errorf("too many http addresses (%d > %d)", len(urls), maxOwnershipURLs) + urls = urls[:maxOwnershipURLs] } var lastErr error for _, raw := range urls { diff --git a/acme/writer.go b/acme/writer.go index 346f2fc..773c2ac 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -50,9 +50,10 @@ type acmeWriter struct { ForgeDomain string ExternalTLS bool - // AllowPrivateAddrs disables destination-IP vetting, the address cap, and - // the dial timeout on the reachability probe. Off by default; intended for - // tests and private deployments that trust the submitted addresses. + // 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 diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 79d7a0e..28a9b3f 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -180,6 +180,11 @@ 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 @@ -309,15 +314,17 @@ denied. ## Operator configuration -Two `acme` block options affect `/v2` (see the README for full syntax): - -- `allow-private-addresses true` turns off destination-IP vetting, the address - cap, and the dial timeout. 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. Without it, only the direct connection address is trusted. +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. Without it, only the direct connection address is trusted. ## Adding a key type From 4732bda9833af0940929fd32360c30679e98a596 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 16:45:17 +0200 Subject: [PATCH 22/27] fix: check the client-IP denylist before signing work A denylisted caller used to pay for a full signature verification (and, on v1, reach the dialback) before the denylist looked at its IP, and the submitted-address check ran in the same late step. Split the denylist check so the client IP is tested right after the auth gate, before any crypto or datastore work; the submitted addresses are still checked once the body is parsed. The trusted client-IP header now accepts "ip:port", not just a bare IP, since some proxies append the source port. An unparseable value is logged instead of silently dropped, which otherwise leaves the denylist quietly matching the proxy's own address. Configuring client-ip-header as X-Forwarded-For is refused at startup: a client can prepend to it and dodge the denylist. --- README.md | 2 +- acme/clientip.go | 20 +++++++++++++++++++- acme/clientip_test.go | 14 ++++++++++++++ acme/setup.go | 7 +++++++ acme/setup_test.go | 38 ++++++++++++++++++++++++++++++++++++++ acme/writer.go | 28 +++++++++++++++++++++------- acme/writer_v2.go | 10 +++++++++- docs/registration-v2.md | 5 ++++- 8 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 acme/setup_test.go diff --git a/README.md b/README.md index 8b15e61..85ad52c 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ acme FORGE_DOMAIN { - **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. Without it, only the direct connection address is trusted; a forged `X-Forwarded-For` is ignored. Request rate limiting is not done by the forge; configure it on your reverse proxy, CDN, or load balancer. +- **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) diff --git a/acme/clientip.go b/acme/clientip.go index ebcd287..dfee0fa 100644 --- a/acme/clientip.go +++ b/acme/clientip.go @@ -22,8 +22,14 @@ func clientIPs(r *http.Request, trustedHeader string) []netip.Addr { if trustedHeader != "" { if v := strings.TrimSpace(r.Header.Get(trustedHeader)); v != "" { - if ip, err := netip.ParseAddr(v); err == nil { + 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) } } } @@ -39,6 +45,18 @@ func clientIPs(r *http.Request, trustedHeader string) []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 7241179..1547788 100644 --- a/acme/clientip_test.go +++ b/acme/clientip_test.go @@ -54,6 +54,20 @@ func TestClientIPs(t *testing.T) { remoteAddr: "1.2.3.4:80", 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: "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: "empty", remoteAddr: "", diff --git a/acme/setup.go b/acme/setup.go index 549d3a3..8f67ebf 100644 --- a/acme/setup.go +++ b/acme/setup.go @@ -119,6 +119,13 @@ func parse(c *caddy.Controller) (*acmeReader, *acmeWriter, error) { 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() 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/writer.go b/acme/writer.go index 773c2ac..07b4549 100644 --- a/acme/writer.go +++ b/acme/writer.go @@ -293,16 +293,23 @@ type requestBody struct { 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 @@ -311,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 index b559504..8cdc88a 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -54,6 +54,14 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { 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") @@ -96,7 +104,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } - if blocked, reason := checkDenylist(clientIPs(r, c.ClientIPHeader), typedBody.Addresses); blocked { + if blocked, reason := denylistAddresses(typedBody.Addresses); blocked { writeProblem(w, http.StatusForbidden, "denylisted", reason) return } diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 28a9b3f..8240786 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -324,7 +324,10 @@ full syntax): 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. Without it, only the direct connection address is trusted. + 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 From d2b1308cf177f963988b9034a477536cae3622f4 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 19:11:36 +0200 Subject: [PATCH 23/27] refactor(client): dedup the challenge send paths The v1 and v2 senders carried separate copies of the same request decoration and error rendering, and the copies had already drifted: v1 never closed the response body and read the error body unbounded, while v2 did both correctly. Pull the shared parts into decorateForgeRequest and renderChallengeError, so v1 now closes the body and bounds the error read like v2. isEd25519 checks the key's Type() instead of asserting a concrete Go type, so a wrapped or delegated Ed25519 key is recognized instead of being routed to v1. Tests pin the v2 status contract the auto fallback relies on: 404 and 405 mean unsupported, 422 does not. --- client/challenge.go | 45 +++++++++++++------- client/challenge_v2.go | 32 +++++---------- client/challenge_v2_test.go | 82 +++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 client/challenge_v2_test.go diff --git a/client/challenge.go b/client/challenge.go index 64478b7..d5c0470 100644 --- a/client/challenge.go +++ b/client/challenge.go @@ -66,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 @@ -109,13 +99,40 @@ 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. diff --git a/client/challenge_v2.go b/client/challenge_v2.go index 86ca57c..d842bf8 100644 --- a/client/challenge_v2.go +++ b/client/challenge_v2.go @@ -58,20 +58,11 @@ func SendChallengeV2(ctx context.Context, baseURL string, privKey crypto.PrivKey return fmt.Errorf("creating request to %s: %w", registrationURL, err) } req.Header.Set("Content-Type", "application/json") - if userAgent == "" { - userAgent = defaultUserAgent - } - req.Header.Set("User-Agent", userAgent) - if forgeAuth != "" { - req.Header.Set(ForgeAuthHeader, forgeAuth) - } - - // modifyForgeRequest runs before signing: it may set req.Host, which is a - // covered component (@authority). - if modifyForgeRequest != nil { - if err := modifyForgeRequest(req); err != nil { - return err - } + // 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 { @@ -88,15 +79,13 @@ func SendChallengeV2(ctx context.Context, baseURL string, privKey crypto.PrivKey } 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 { - respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) - if readErr != nil { - return fmt.Errorf("%s from %s (reading error body failed: %w)", resp.Status, registrationURL, readErr) - } - return fmt.Errorf("%s error from %s: %q", resp.Status, registrationURL, respBody) + return renderChallengeError(resp, registrationURL) } return nil } @@ -120,9 +109,10 @@ func marshalChallengeBody(challenge string, addrs []multiaddr.Multiaddr) ([]byte } // 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 { - _, ok := k.(*crypto.Ed25519PrivateKey) - return ok + return k.Type() == crypto.Ed25519 } // signV2Request signs req in place for the /v2 profile: an RFC 9421 signature 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") +} From fe4253bbd4c164bb3115ac7d534234a4fb7e8dd9 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 19:24:59 +0200 Subject: [PATCH 24/27] refactor(client): extract and test the v2/v1 dispatch The version selection and auto-mode fallback lived inline in Present, tangled with the libp2p host and HTTP calls, so nothing tested that auto falls back only when the forge lacks a v2 endpoint and not on a verification failure. Pull the rule into a register method taking the two send funcs; behavior is unchanged. Document the dialback assumption on WithRegistrationAPIVersion: in the certmagic flow the node advertises libp2p addresses, so v2 and auto are verified by the forge's dialback. A forge offering only the http-ownership proof is reached through SendChallengeV2 with an http address, not this option. --- client/acme.go | 45 ++++++++++++++++++------------- client/solver_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 client/solver_test.go diff --git a/client/acme.go b/client/acme.go index 963430c..f491e64 100644 --- a/client/acme.go +++ b/client/acme.go @@ -174,6 +174,11 @@ 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 { @@ -751,32 +756,34 @@ func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challe advertisedAddrs, d.forgeAuth, d.userAgent, d.modifyForgeRequest, sendOpts...) } - var err error + 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: - err = sendV2() + return sendV2() case RegistrationAuto: - // v2 needs an Ed25519 key; fall back to v1 when the key is unsupported - // or the forge has no v2 endpoint. - if isEd25519(privKey) { - if err = sendV2(); err == nil { - return nil - } - if !errors.Is(err, ErrV2Unsupported) { - return fmt.Errorf("p2p-forge broker registration error: %w", err) - } - d.log.Infow("v2 registration unavailable, falling back to v1", "err", err) - } else { + if !isEd25519 { d.log.Debugw("identity key is not Ed25519, using v1 registration") + return sendV1() } - err = 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: - err = sendV1() + return sendV1() } - if err != nil { - return fmt.Errorf("p2p-forge broker registration error: %w", err) - } - return nil } func (d *dns01P2PForgeSolver) CleanUp(ctx context.Context, challenge acme.Challenge) error { 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) + }) +} From 977df63126222f69dae3afa015353dfcf2bbf54e Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 19:31:08 +0200 Subject: [PATCH 25/27] fix: make the v2 success body and error links honest The success body had two rough edges: the retention duration was named ttl but read like a DNS TTL (the record's real TTL is 10s), and verification wrapped a single string in an object "for future fields". Rename it expiresIn (seconds until the stored value expires), drive it and the PutWithTTL call from one challengeTTL constant, and flatten verification to the string it always was. The whole body is informational; the shipped client reads none of it. The problem type URIs pointed at doc fragments (#malformed-body and the rest) that GitHub never generated, since the errors were table rows, not headings, so every link landed at the top of the page. Add an anchor per slug so each type URI resolves to its own row. --- acme/ownership_test.go | 2 +- acme/writer_v2.go | 27 +++++++++++++++++---------- acme/writer_v2_test.go | 2 +- docs/registration-v2.md | 38 +++++++++++++++++++++++--------------- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/acme/ownership_test.go b/acme/ownership_test.go index 8665ed4..f0d9519 100644 --- a/acme/ownership_test.go +++ b/acme/ownership_test.go @@ -111,7 +111,7 @@ func TestV2HTTPOwnershipEndToEnd(t *testing.T) { var resp v2Response require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - require.Equal(t, "http-ownership", resp.Verification.Mode) + require.Equal(t, "http-ownership", resp.Verification) } func TestCanonicalOriginServer(t *testing.T) { diff --git a/acme/writer_v2.go b/acme/writer_v2.go index 8cdc88a..5fa065c 100644 --- a/acme/writer_v2.go +++ b/acme/writer_v2.go @@ -26,6 +26,10 @@ const ( // 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. @@ -119,7 +123,7 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { return } - if err := c.Datastore.PutWithTTL(r.Context(), datastore.NewKey(peerID.String()), []byte(typedBody.Value), time.Hour); err != nil { + 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 @@ -128,22 +132,25 @@ func (c *acmeWriter) handleV2Challenge(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, v2Response{ DID: verified.keyID, Name: certWildcard(peerID, c.ForgeDomain), - Verification: v2Verification{Mode: mode}, - TTL: int(time.Hour / time.Second), + 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 v2Verification `json:"verification"` - TTL int `json:"ttl"` -} - -type v2Verification struct { - Mode string `json:"mode"` + 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 diff --git a/acme/writer_v2_test.go b/acme/writer_v2_test.go index 7a8bbe0..838dfb9 100644 --- a/acme/writer_v2_test.go +++ b/acme/writer_v2_test.go @@ -190,7 +190,7 @@ func TestV2ChallengeHandlerRoundTrip(t *testing.T) { 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.Mode) + require.Equal(t, "libp2p-dialback", resp.Verification) } func TestV2ChallengeHandlerRejects(t *testing.T) { diff --git a/docs/registration-v2.md b/docs/registration-v2.md index 8240786..708aca1 100644 --- a/docs/registration-v2.md +++ b/docs/registration-v2.md @@ -151,17 +151,24 @@ to prove reachability (below). ### Success -`200` with: +`200` with a body that is informational: a client needs none of it to proceed. ```json { "did": "did:key:z6Mk...", "name": "*..libp2p.direct", - "verification": {"mode": "http-ownership"}, - "ttl": 3600 + "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 @@ -278,21 +285,22 @@ 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 carries -a stable fragment at the end of its `type` URI; a client that needs to tell -classes apart SHOULD match on that fragment. +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. | +| `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 From 90b94f44f690b86b8ff994d4a6e072e687afec73 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 19:36:28 +0200 Subject: [PATCH 26/27] docs: fix and clarify the key-types support matrix Several claims in key-types.md were wrong or overstated, and the support status was buried in prose: - IANA does not list ml-dsa-44/65/87. Those names come from C2SP httpsig-pq v1.0.0, which requests their registration; the IANA HTTP Message Signature Algorithms registry holds only the six RFC 9421 algorithms. Fixed the sentence and the table. - Both tables gain a Status column: Ed25519 is Supported, every other classical and post-quantum type is Unsupported, so a reader sees at a glance what works instead of parsing notes. - ECDSA needs both a compressed-point key conversion and a DER-to-r||s signature conversion; secp256k1 has no registered algorithm; RSA is compatible but not enabled. The notes say what each would need before it could be turned on. - All the multicodec code points, 0xed included, are registry status draft; the text no longer implies the classical rows are firmer than the post-quantum ones. - Cite W3C Controlled Identifiers 1.0 (a Recommendation) as the normative source for the Ed25519 did:key encoding. --- docs/key-types.md | 59 ++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/docs/key-types.md b/docs/key-types.md index 5c3d220..c0d73fe 100644 --- a/docs/key-types.md +++ b/docs/key-types.md @@ -14,9 +14,12 @@ registry rather than invented here: ([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. 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. + 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 @@ -55,16 +58,20 @@ peer ID is at most 63 octets. ## Classical key types -libp2p keys map to multicodecs and RFC 9421 algorithms as follows. Only the -first is enabled today. +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. -| Key | Multicodec | RFC 9421 algorithm | Notes | -| --- | --- | --- | --- | -| Ed25519 | `0xed` | `ed25519` | Enabled. libp2p signs raw 64-byte Ed25519, which is exactly what RFC 9421 `ed25519` expects, so it interoperates with off-the-shelf tooling. This is why it is the primary. | -| ECDSA P-256 | `0x1200` | `ecdsa-p256-sha256` | Registered, but an encoding gap: libp2p signs ASN.1 DER, while RFC 9421 (section 3.3) mandates the fixed-width `r \|\| s` form (as in JWS). An implementation MUST convert between them. | -| ECDSA P-384 | `0x1201` | `ecdsa-p384-sha384` | Same encoding gap as P-256. | -| RSA (PKCS#1 v1.5, SHA-256) | `0x1205` | `rsa-v1_5-sha256` | Registered and encoding-compatible. RSA signatures and keys are large, so mind the request size limits. | -| secp256k1 | `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). Treat as a last resort until a registration exists. | +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 @@ -83,19 +90,21 @@ The identifier and algorithm layers are already landing: - [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 IANA HTTP Message Signature Algorithms registry already lists `ml-dsa-44`, - `ml-dsa-65`, and `ml-dsa-87` as Active, defined by the C2SP - [`httpsig-pq`](https://c2sp.org/httpsig-pq) specification. +- 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 | Multicodec | RFC 9421 algorithm | Status | -| --- | --- | --- | --- | -| ML-DSA-44 / 65 / 87 | `0x1210` / `0x1211` / `0x1212` (draft) | `ml-dsa-44` / `ml-dsa-65` / `ml-dsa-87` (Active) | Identifiers exist on both layers; blocked on libp2p key support. | -| SLH-DSA (all parameter sets) | `0x1220`+ (draft) | none | No RFC 9421 algorithm yet, and signatures are large (see below). | +| 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: @@ -125,11 +134,13 @@ too large to carry in a header for now. - 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 the DER-to-`r \|\| s` conversion before enabling the type, - and add a test vector, so non-Go and non-libp2p signers can interoperate. -- Do not invent an `alg` identifier when a registered one fits. Adopt the - published multicodec and IANA RFC 9421 identifiers (for example the `ml-dsa-*` - names above) rather than a local name. +- 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. From 6bd03f90a0337682dbf8e80538c91c63508007be Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 24 Jul 2026 19:37:38 +0200 Subject: [PATCH 27/27] docs: align changelog with the final v2 config allow-private-addresses is an argument on the registration-domain line, not a standalone block option, and it disables all reachability safeguards, not just IP vetting. Note that client-ip-header refuses X-Forwarded-For. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5eadc7..e337a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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` block options: `allow-private-addresses` (turns off destination-IP vetting for local testing or trusted private deployments, default false) and `client-ip-header` (names the trusted proxy header for the real client IP, e.g. `CF-Connecting-IP`). +- 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.