Create basic webhook for SCEP challenge validation

pull/1366/head
Herman Slatman 1 year ago
parent 1420c762e0
commit 05f7ab979f
No known key found for this signature in database
GPG Key ID: F4D8A44EA0A75A4F

@ -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

@ -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=

@ -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"))
}
}
}

@ -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
}
}

@ -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"`
}

@ -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
}

@ -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")

Loading…
Cancel
Save