Skip to content

Commit

Permalink
Extract helpers for signing/verifying requests
Browse files Browse the repository at this point in the history
  • Loading branch information
chriso committed Jun 3, 2024
1 parent cd68d50 commit 23e6134
Show file tree
Hide file tree
Showing 2 changed files with 146 additions and 65 deletions.
77 changes: 12 additions & 65 deletions dispatch.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
134 changes: 134 additions & 0 deletions internal/auth/signature.go
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit 23e6134

Please sign in to comment.