Create basic webhook for SCEP challenge validation
This commit is contained in:
parent
1420c762e0
commit
05f7ab979f
7 changed files with 253 additions and 13 deletions
4
go.mod
4
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
|
||||
|
|
7
go.sum
7
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=
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
24
scep/api/webhook/options.go
Normal file
24
scep/api/webhook/options.go
Normal file
|
@ -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
|
||||
}
|
||||
}
|
161
scep/api/webhook/webhook.go
Normal file
161
scep/api/webhook/webhook.go
Normal file
|
@ -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…
Reference in a new issue