From 05f7ab979f2aafe61489aae08cf8edb8934fa351 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Fri, 28 Apr 2023 15:47:22 +0200 Subject: [PATCH] Create basic webhook for SCEP challenge validation --- go.mod | 4 + go.sum | 7 ++ scep/api/api.go | 58 +++++++++++-- scep/api/webhook/options.go | 24 ++++++ scep/api/webhook/webhook.go | 161 ++++++++++++++++++++++++++++++++++++ scep/authority.go | 8 +- scep/common.go | 4 +- 7 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 scep/api/webhook/options.go create mode 100644 scep/api/webhook/webhook.go diff --git a/go.mod b/go.mod index 0b59f165..a469dcb6 100644 --- a/go.mod +++ b/go.mod @@ -106,6 +106,8 @@ require ( github.com/jackc/pgx/v4 v4.18.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/compress v1.15.11 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-colorable v0.1.8 // indirect @@ -119,8 +121,10 @@ require ( github.com/peterbourgon/diskv/v3 v3.0.1 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/ryboe/q v1.0.19 // indirect github.com/schollz/jsonstore v1.1.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect diff --git a/go.sum b/go.sum index 7f417b36..f91d5a9c 100644 --- a/go.sum +++ b/go.sum @@ -657,6 +657,8 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -794,6 +796,7 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -844,6 +847,8 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= @@ -859,6 +864,8 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/ryboe/q v1.0.19 h1:1dO1anK4gorZRpXBD/edBZkMxIC1tFIwN03nfyOV13A= +github.com/ryboe/q v1.0.19/go.mod h1:IoEB3Q2/p6n1qbhIQVuNyakxtnV4rNJ/XJPK+jsEa0M= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= diff --git a/scep/api/api.go b/scep/api/api.go index 346b9c75..66118388 100644 --- a/scep/api/api.go +++ b/scep/api/api.go @@ -14,12 +14,14 @@ import ( "github.com/go-chi/chi" microscep "github.com/micromdm/scep/v2/scep" + "github.com/ryboe/q" "go.mozilla.org/pkcs7" "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/api/log" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/scep" + "github.com/smallstep/certificates/scep/api/webhook" ) const ( @@ -306,19 +308,61 @@ func PKIOperation(ctx context.Context, req request) (Response, error) { // NOTE: at this point we have sufficient information for returning nicely signed CertReps csr := msg.CSRReqMessage.CSR + prov, err := scep.ProvisionerFromContext(ctx) // TODO(hs): should this be retrieved in the API? + if err != nil { + return Response{}, err + } + + _ = prov + q.Q(prov) + + // TODO(hs): set the checking method based on what's configured in provisioner. Or try something dynamic. + const checkMethodWebhook string = "webhook" + checkMethod := checkMethodWebhook + // NOTE: we're blocking the RenewalReq if the challenge does not match, because otherwise we don't have any authentication. // The macOS SCEP client performs renewals using PKCSreq. The CertNanny SCEP client will use PKCSreq with challenge too, it seems, // even if using the renewal flow as described in the README.md. MicroMDM SCEP client also only does PKCSreq by default, unless // a certificate exists; then it will use RenewalReq. Adding the challenge check here may be a small breaking change for clients. // We'll have to see how it works out. if msg.MessageType == microscep.PKCSReq || msg.MessageType == microscep.RenewalReq { - challengeMatches, err := auth.MatchChallengePassword(ctx, msg.CSRReqMessage.ChallengePassword) - if err != nil { - return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("error when checking password")) - } - if !challengeMatches { - // TODO: can this be returned safely to the client? In the end, if the password was correct, that gains a bit of info too. - return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("wrong password provided")) + // TODO(hs): might be nice use strategy pattern implementation; maybe behind the + // auth.MatchChallengePassword interface/method. Will need to think about methods + // that don't just check the password, but do different things on success and + // failure too. + switch checkMethod { + case checkMethodWebhook: + // TODO(hs): implement webhook call: needs endpoint, auth, request body + // TODO(hs): integrate this with the existing webhook implementation by extending it + fmt.Println("test") + q.Q("HERE") + q.Q(msg.CSRReqMessage) + opts := []webhook.ControllerOption{ + webhook.WithURL("http://127.0.0.1:8081/scepvalidate"), + webhook.WithBearerToken("fake-token"), + } + c, err := webhook.New(opts...) + if err != nil { + return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("failed creating SCEP validation webhook controller")) + } + q.Q(c) + ok, err := c.Validate(msg.CSRReqMessage.ChallengePassword) + if err != nil { + q.Q(err) + return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("failed validating challenge password")) + } + if !ok { + return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("wrong challenge password provided")) + } + default: + challengeMatches, err := auth.MatchChallengePassword(ctx, msg.CSRReqMessage.ChallengePassword) + if err != nil { + return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("error when checking password")) + } + if !challengeMatches { + // TODO: can this be returned safely to the client? In the end, if the password was correct, that gains a bit of info too. + return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("wrong chalenge password provided")) + } } } diff --git a/scep/api/webhook/options.go b/scep/api/webhook/options.go new file mode 100644 index 00000000..ce809cb4 --- /dev/null +++ b/scep/api/webhook/options.go @@ -0,0 +1,24 @@ +package webhook + +type ControllerOption func(*Controller) error + +func WithURL(url string) ControllerOption { + return func(c *Controller) error { + c.webhook.URL = url + return nil + } +} + +func WithBearerToken(token string) ControllerOption { + return func(c *Controller) error { + c.webhook.BearerToken = token + return nil + } +} + +func WithDisableTLSClientAuth(enabled bool) ControllerOption { + return func(c *Controller) error { + c.webhook.DisableTLSClientAuth = enabled + return nil + } +} diff --git a/scep/api/webhook/webhook.go b/scep/api/webhook/webhook.go new file mode 100644 index 00000000..d3474c14 --- /dev/null +++ b/scep/api/webhook/webhook.go @@ -0,0 +1,161 @@ +package webhook + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "time" + + "github.com/ryboe/q" +) + +type Controller struct { + client *http.Client + webhook *Webhook +} + +func New(options ...ControllerOption) (*Controller, error) { + c := &Controller{ + client: http.DefaultClient, + webhook: &Webhook{}, + } + for _, apply := range options { + if err := apply(c); err != nil { + return nil, err + } + } + return c, nil +} + +func (c *Controller) Validate(challenge string) (bool, error) { + req := &Request{ + Challenge: challenge, + } + client := c.client + if client == nil { + client = http.DefaultClient + } + resp, err := c.webhook.Do(client, req) + if err != nil { + q.Q(err) + return false, fmt.Errorf("failed performing webhook request: %w", err) + } + + if resp == nil { + return false, nil + } + + return true, nil +} + +type Webhook struct { + URL string + DisableTLSClientAuth bool + Secret string + BearerToken string + BasicAuth struct { + Username string + Password string + } +} + +func (w *Webhook) Do(client *http.Client, req *Request) (*Response, error) { + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + + retries := 1 +retry: + + r, err := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewReader(reqBytes)) + if err != nil { + return nil, err + } + + if w.Secret != "" { + secret, err := base64.StdEncoding.DecodeString(w.Secret) + if err != nil { + return nil, err + } + sig := hmac.New(sha256.New, secret).Sum(reqBytes) + r.Header.Set("X-Smallstep-Signature", hex.EncodeToString(sig)) + //req.Header.Set("X-Smallstep-Webhook-ID", w.ID) + } + + if w.BearerToken != "" { + r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", w.BearerToken)) + } else if w.BasicAuth.Username != "" || w.BasicAuth.Password != "" { + r.SetBasicAuth(w.BasicAuth.Username, w.BasicAuth.Password) + } + if w.DisableTLSClientAuth { + transport, ok := client.Transport.(*http.Transport) + if !ok { + return nil, errors.New("client transport is not a *http.Transport") + } + transport = transport.Clone() + tlsConfig := transport.TLSClientConfig.Clone() + tlsConfig.GetClientCertificate = nil + tlsConfig.Certificates = nil + transport.TLSClientConfig = tlsConfig + client = &http.Client{ + Transport: transport, + } + } + + resp, err := client.Do(r) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, err + } else if retries > 0 { + retries-- + time.Sleep(time.Second) + goto retry + } + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + // TODO: return this error instead of (just) logging? + log.Printf("failed to close body of response from %s", w.URL) + } + }() + + if resp.StatusCode >= 500 && retries > 0 { + retries-- + time.Sleep(time.Second) + goto retry + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("webhook server responded with %d", resp.StatusCode) + } + + respBody := &Response{} + // TODO: decide on the JSON structure for the response (if any); HTTP status code may be enough. + // if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil { + // return nil, err + // } + + return respBody, nil +} + +type Request struct { + Challenge string `json:"challenge"` +} + +type Response struct { + // TODO: define expected response format? Or do we consider 200 OK enough? + Allow bool `json:"allow"` +} diff --git a/scep/authority.go b/scep/authority.go index 585b937e..9bfa20b8 100644 --- a/scep/authority.go +++ b/scep/authority.go @@ -161,7 +161,7 @@ func (a *Authority) GetCACertificates(ctx context.Context) ([]*x509.Certificate, // The certificate to use should probably depend on the (configured) provisioner and may // use a distinct certificate, apart from the intermediate. - p, err := provisionerFromContext(ctx) + p, err := ProvisionerFromContext(ctx) if err != nil { return nil, err } @@ -235,7 +235,7 @@ func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, m // poll for the status. It seems to be similar as what can happen in ACME, so might want to model // the implementation after the one in the ACME authority. Requires storage, etc. - p, err := provisionerFromContext(ctx) + p, err := ProvisionerFromContext(ctx) if err != nil { return nil, err } @@ -458,7 +458,7 @@ func (a *Authority) CreateFailureResponse(ctx context.Context, csr *x509.Certifi // MatchChallengePassword verifies a SCEP challenge password func (a *Authority) MatchChallengePassword(ctx context.Context, password string) (bool, error) { - p, err := provisionerFromContext(ctx) + p, err := ProvisionerFromContext(ctx) if err != nil { return false, err } @@ -476,7 +476,7 @@ func (a *Authority) MatchChallengePassword(ctx context.Context, password string) // GetCACaps returns the CA capabilities func (a *Authority) GetCACaps(ctx context.Context) []string { - p, err := provisionerFromContext(ctx) + p, err := ProvisionerFromContext(ctx) if err != nil { return defaultCapabilities } diff --git a/scep/common.go b/scep/common.go index 73b16ed4..ca87841f 100644 --- a/scep/common.go +++ b/scep/common.go @@ -14,9 +14,9 @@ const ( ProvisionerContextKey = ContextKey("provisioner") ) -// provisionerFromContext searches the context for a SCEP provisioner. +// ProvisionerFromContext searches the context for a SCEP provisioner. // Returns the provisioner or an error. -func provisionerFromContext(ctx context.Context) (Provisioner, error) { +func ProvisionerFromContext(ctx context.Context) (Provisioner, error) { val := ctx.Value(ProvisionerContextKey) if val == nil { return nil, errors.New("provisioner expected in request context")