diff --git a/dispatch.go b/dispatch.go index 5a315c4..5cf3607 100644 --- a/dispatch.go +++ b/dispatch.go @@ -1,22 +1,18 @@ package dispatch import ( - "bytes" "context" "crypto/ed25519" - "io" "log/slog" "net/http" "strings" "sync" - "time" "buf.build/gen/go/stealthrocket/dispatch-proto/connectrpc/go/dispatch/sdk/v1/sdkv1connect" sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1" "connectrpc.com/connect" "connectrpc.com/validate" "github.com/dispatchrun/dispatch-go/internal/auth" - "github.com/offblocks/httpsig" ) // Dispatch is a Dispatch endpoint. @@ -61,14 +57,23 @@ func (d *Dispatch) Handler(opts ...connect.HandlerOption) (string, http.Handler, opts = append(opts, connect.WithInterceptors(interceptor)) path, handler := sdkv1connect.NewFunctionServiceHandler(&dispatchFunctionServiceHandler{d}, opts...) - handler, err = d.validateSignatures(handler) + + // Setup request signature verification. + verificationKey, err := d.verificationKey() if err != nil { return "", nil, err + } else if verificationKey == nil { + if endpoint := d.endpoint(); !strings.HasPrefix(endpoint, "bridge://") { + // Don't print this warning when running under the CLI. + slog.Warn("request signature validation is disabled") + } + return path, handler, nil } - return path, handler, nil + verifier := auth.NewVerifier(verificationKey) + return path, verifier.Middleware(handler), nil } -// The gRPC handler is unexported. This is so that the http.Handler can be +// The gRPC handler is unexported. This is so that the http.Handler // wrapped in order to validate request signatures. type dispatchFunctionServiceHandler struct { dispatch *Dispatch @@ -79,64 +84,6 @@ func (d *dispatchFunctionServiceHandler) Run(ctx context.Context, req *connect.R return connect.NewResponse(res), nil } -func (d *Dispatch) validateSignatures(next http.Handler) (http.Handler, error) { - key, err := d.verificationKey() - if err != nil { - return nil, err - } - if key == nil { - // Don't print this warning when running under the CLI. - if endpoint := d.endpoint(); !strings.HasPrefix(endpoint, "bridge://") { - slog.Warn("request signature validation is disabled") - } - return next, nil - } - - verifier := httpsig.NewVerifier( - httpsig.WithVerifyEd25519("default", key), - httpsig.WithVerifyAll(true), - httpsig.WithVerifyMaxAge(5*time.Minute), - httpsig.WithVerifyTolerance(5*time.Second), - // The httpsig library checks the strings below against marshaled - // httpsfv items, hence the double quoting. - httpsig.WithVerifyRequiredFields(`"@method"`, `"@path"`, `"@authority"`, `"content-type"`, `"content-digest"`), - ) - - digestor := httpsig.NewDigestor(httpsig.WithDigestAlgorithms(httpsig.DigestAlgorithmSha512)) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Read the body into memory so that the Content-Digest header - // can be verified. - // TODO: put a limit on the read - body, err := io.ReadAll(r.Body) - _ = r.Body.Close() - if err != nil { - slog.Warn("failed to read request body", "err", err) - w.WriteHeader(http.StatusBadRequest) - return - } - r.Body = io.NopCloser(bytes.NewReader(body)) - - if _, ok := r.Header[httpsig.ContentDigestHeader]; !ok { - slog.Warn("missing content digest header") - w.WriteHeader(http.StatusBadRequest) - return - } else if err := digestor.Verify(body, r.Header); err != nil { - slog.Warn("invalid content digest header", "error", err) - w.WriteHeader(http.StatusForbidden) - return - } - - if err := verifier.Verify(httpsig.MessageFromRequest(r)); err != nil { - slog.Warn("missing or invalid request signature", "error", err) - w.WriteHeader(http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - }), nil -} - // ListenAndServe serves the Dispatch endpoint on the specified address. func (d *Dispatch) ListenAndServe(addr string) error { path, handler, err := d.Handler() diff --git a/internal/auth/signature.go b/internal/auth/signature.go new file mode 100644 index 0000000..c747913 --- /dev/null +++ b/internal/auth/signature.go @@ -0,0 +1,134 @@ +package auth + +import ( + "bytes" + "crypto/ed25519" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/offblocks/httpsig" +) + +var digestor = httpsig.NewDigestor(httpsig.WithDigestAlgorithms(httpsig.DigestAlgorithmSha512)) + +// Signer signs HTTP requests. +type Signer struct { + signer *httpsig.Signer +} + +// NewSigner creates a Signer that signs HTTP requests using the specified +// signing key, in the same way that Dispatch would sign requests. +func NewSigner(signingKey ed25519.PrivateKey) *Signer { + return &Signer{ + signer: httpsig.NewSigner( + httpsig.WithSignName("dispatch"), + httpsig.WithSignEd25519("default", signingKey), + httpsig.WithSignFields("@method", "@path", "@authority", "content-type", "content-digest"), + ), + } +} + +// Sign signs a request. +func (s *Signer) Sign(req *http.Request) error { + body, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return fmt.Errorf("failed to read request body: %w", err) + } + req.Body = io.NopCloser(bytes.NewReader(body)) + + // Generate the Content-Digest header. + digestHeaders, err := digestor.Digest(body) + if err != nil { + return fmt.Errorf("failed to generate content digest: %w", err) + } + for name, values := range digestHeaders { + req.Header[name] = append(req.Header[name], values...) + } + + // Sign the request. + headers, err := s.signer.Sign(httpsig.MessageFromRequest(req)) + if err != nil { + return fmt.Errorf("failed to sign request: %w", err) + } + req.Header = headers + return nil +} + +// Verifier verifies that requests were signed by Dispatch. +type Verifier struct { + verifier *httpsig.Verifier +} + +// NewVerifier creates a Verifier that verifies that requests were +// by Dispatch using the private key associated with this public +// verification key. +func NewVerifier(verificationKey ed25519.PublicKey) *Verifier { + verifier := httpsig.NewVerifier( + httpsig.WithVerifyEd25519("default", verificationKey), + httpsig.WithVerifyAll(true), + httpsig.WithVerifyMaxAge(5*time.Minute), + httpsig.WithVerifyTolerance(5*time.Second), + // The httpsig library checks the strings below against marshaled + // httpsfv items, hence the double quoting. + httpsig.WithVerifyRequiredFields(`"@method"`, `"@path"`, `"@authority"`, `"content-type"`, `"content-digest"`), + ) + return &Verifier{verifier} +} + +// Verify verifies that a request was signed by Dispatch. +func (v *Verifier) Verify(r *http.Request) error { + body, err := io.ReadAll(r.Body) + _ = r.Body.Close() + if err != nil { + return fmt.Errorf("failed to read request body: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + // Verify the Content-Digest header. + if _, ok := r.Header[httpsig.ContentDigestHeader]; !ok { + return fmt.Errorf("missing Content-Digest header") + } else if err := digestor.Verify(body, r.Header); err != nil { + return fmt.Errorf("invalid Content-Digest header: %w", err) + } + + // Verify the signature. + if err := v.verifier.Verify(httpsig.MessageFromRequest(r)); err != nil { + return fmt.Errorf("missing or invalid signature: %w", err) + } + return nil +} + +// Middleware wraps an HTTP handler in order to validate request signatures. +func (v *Verifier) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := v.Verify(r); err != nil { + slog.Warn("request was not signed correctly", "error", err) + w.WriteHeader(http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// Client wraps an HTTP client in order to sign requests. +func (s *Signer) Client(client *http.Client) *SigningClient { + return &SigningClient{client, s} +} + +// SigningClient is an HTTP client that automatically signs requests. +type SigningClient struct { + client *http.Client + signer *Signer +} + +// Do signs and sends an HTTP request, and returns the HTTP response. +func (c *SigningClient) Do(req *http.Request) (*http.Response, error) { + if err := c.signer.Sign(req); err != nil { + return nil, fmt.Errorf("failed to sign request: %w", err) + } + return c.client.Do(req) +}