Merge pull request #2065 from smallstep/mariano/challenge-webhook

Add data support on SCEPCHALLENGE webhooks
This commit is contained in:
Mariano Cano
2024-11-12 14:09:24 -08:00
committed by GitHub
7 changed files with 353 additions and 52 deletions

View File

@@ -15,6 +15,7 @@ import (
"go.step.sm/crypto/kms"
kmsapi "go.step.sm/crypto/kms/apiv1"
"go.step.sm/crypto/x509util"
"go.step.sm/linkedca"
"github.com/smallstep/certificates/webhook"
@@ -145,25 +146,33 @@ var (
// that case, the other webhooks will be skipped. If none of
// the webhooks indicates the value of the challenge was accepted,
// an error is returned.
func (c *challengeValidationController) Validate(ctx context.Context, csr *x509.CertificateRequest, provisionerName, challenge, transactionID string) error {
func (c *challengeValidationController) Validate(ctx context.Context, csr *x509.CertificateRequest, provisionerName, challenge, transactionID string) ([]SignCSROption, error) {
var opts []SignCSROption
for _, wh := range c.webhooks {
req, err := webhook.NewRequestBody(webhook.WithX509CertificateRequest(csr))
if err != nil {
return fmt.Errorf("failed creating new webhook request: %w", err)
return nil, fmt.Errorf("failed creating new webhook request: %w", err)
}
req.ProvisionerName = provisionerName
req.SCEPChallenge = challenge
req.SCEPTransactionID = transactionID
resp, err := wh.DoWithContext(ctx, c.client, req, nil) // TODO(hs): support templated URL? Requires some refactoring
if err != nil {
return fmt.Errorf("failed executing webhook request: %w", err)
return nil, fmt.Errorf("failed executing webhook request: %w", err)
}
if resp.Allow {
return nil // return early when response is positive
opts = append(opts, TemplateDataModifierFunc(func(data x509util.TemplateData) {
data.SetWebhook(wh.Name, resp.Data)
}))
}
}
return ErrSCEPChallengeInvalid
if len(opts) == 0 {
return nil, ErrSCEPChallengeInvalid
}
return opts, nil
}
type notificationController struct {
@@ -440,18 +449,18 @@ func (s *SCEP) GetContentEncryptionAlgorithm() int {
// ValidateChallenge validates the provided challenge. It starts by
// selecting the validation method to use, then performs validation
// according to that method.
func (s *SCEP) ValidateChallenge(ctx context.Context, csr *x509.CertificateRequest, challenge, transactionID string) error {
func (s *SCEP) ValidateChallenge(ctx context.Context, csr *x509.CertificateRequest, challenge, transactionID string) ([]SignCSROption, error) {
if s.challengeValidationController == nil {
return fmt.Errorf("provisioner %q wasn't initialized", s.Name)
return nil, fmt.Errorf("provisioner %q wasn't initialized", s.Name)
}
switch s.selectValidationMethod() {
case validationMethodWebhook:
return s.challengeValidationController.Validate(ctx, csr, s.Name, challenge, transactionID)
default:
if subtle.ConstantTimeCompare([]byte(s.ChallengePassword), []byte(challenge)) == 0 {
return errors.New("invalid challenge password provided")
return nil, errors.New("invalid challenge password provided")
}
return nil
return []SignCSROption{}, nil
}
}

View File

@@ -22,6 +22,7 @@ import (
"go.step.sm/crypto/kms/softkms"
"go.step.sm/crypto/minica"
"go.step.sm/crypto/pemutil"
"go.step.sm/crypto/x509util"
"go.step.sm/linkedca"
)
@@ -37,6 +38,7 @@ func Test_challengeValidationController_Validate(t *testing.T) {
}
type response struct {
Allow bool `json:"allow"`
Data any `json:"data"`
}
nokServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req := &request{}
@@ -60,11 +62,22 @@ func Test_challengeValidationController_Validate(t *testing.T) {
if assert.NotNil(t, req.Request) {
assert.Equal(t, []byte{1}, req.Request.Raw)
}
b, err := json.Marshal(response{Allow: true})
resp := response{Allow: true}
if r.Header.Get("X-Smallstep-Webhook-Id") == "webhook-id-2" {
resp.Data = map[string]any{
"ID": "2adcbfec-5e4a-4b93-8913-640e24faf101",
"Email": "admin@example.com",
}
}
b, err := json.Marshal(resp)
require.NoError(t, err)
w.WriteHeader(200)
w.Write(b)
}))
t.Cleanup(func() {
nokServer.Close()
okServer.Close()
})
type fields struct {
client *http.Client
webhooks []*Webhook
@@ -78,7 +91,7 @@ func Test_challengeValidationController_Validate(t *testing.T) {
name string
fields fields
args args
server *httptest.Server
want x509util.TemplateData
expErr error
}{
{
@@ -134,7 +147,6 @@ func Test_challengeValidationController_Validate(t *testing.T) {
challenge: "not-allowed",
transactionID: "transaction-1",
},
server: nokServer,
expErr: errors.New("webhook server did not allow request"),
},
{
@@ -154,26 +166,58 @@ func Test_challengeValidationController_Validate(t *testing.T) {
challenge: "challenge",
transactionID: "transaction-1",
},
server: okServer,
want: x509util.TemplateData{
x509util.WebhooksKey: map[string]any{
"webhook-name-1": nil,
},
},
},
{
name: "ok with data",
fields: fields{http.DefaultClient, []*Webhook{
{
ID: "webhook-id-2",
Name: "webhook-name-2",
Secret: "MTIzNAo=",
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
CertType: linkedca.Webhook_X509.String(),
URL: okServer.URL,
},
}},
args: args{
provisionerName: "my-scep-provisioner",
challenge: "challenge",
transactionID: "transaction-1",
},
want: x509util.TemplateData{
x509util.WebhooksKey: map[string]any{
"webhook-name-2": map[string]any{
"ID": "2adcbfec-5e4a-4b93-8913-640e24faf101",
"Email": "admin@example.com",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := newChallengeValidationController(tt.fields.client, tt.fields.webhooks)
if tt.server != nil {
defer tt.server.Close()
}
ctx := context.Background()
err := c.Validate(ctx, dummyCSR, tt.args.provisionerName, tt.args.challenge, tt.args.transactionID)
got, err := c.Validate(ctx, dummyCSR, tt.args.provisionerName, tt.args.challenge, tt.args.transactionID)
if tt.expErr != nil {
assert.EqualError(t, err, tt.expErr.Error())
return
}
assert.NoError(t, err)
data := x509util.TemplateData{}
for _, o := range got {
if m, ok := o.(TemplateDataModifier); ok {
m.Modify(data)
} else {
t.Errorf("Validate() got = %T, want TemplateDataModifier", o)
}
}
assert.Equal(t, tt.want, data)
})
}
}
@@ -257,6 +301,7 @@ func TestSCEP_ValidateChallenge(t *testing.T) {
}
type response struct {
Allow bool `json:"allow"`
Data any `json:"data"`
}
okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req := &request{}
@@ -268,11 +313,19 @@ func TestSCEP_ValidateChallenge(t *testing.T) {
if assert.NotNil(t, req.Request) {
assert.Equal(t, []byte{1}, req.Request.Raw)
}
b, err := json.Marshal(response{Allow: true})
resp := response{Allow: true}
if r.Header.Get("X-Smallstep-Webhook-Id") == "webhook-id-2" {
resp.Data = map[string]any{
"ID": "2adcbfec-5e4a-4b93-8913-640e24faf101",
"Email": "admin@example.com",
}
}
b, err := json.Marshal(resp)
require.NoError(t, err)
w.WriteHeader(200)
w.Write(b)
}))
t.Cleanup(okServer.Close)
type args struct {
challenge string
transactionID string
@@ -282,6 +335,7 @@ func TestSCEP_ValidateChallenge(t *testing.T) {
p *SCEP
server *httptest.Server
args args
want x509util.TemplateData
expErr error
}{
{"ok/webhooks", &SCEP{
@@ -299,9 +353,43 @@ func TestSCEP_ValidateChallenge(t *testing.T) {
},
},
},
}, okServer, args{"webhook-challenge", "webhook-transaction-1"},
nil,
},
}, okServer, args{"webhook-challenge", "webhook-transaction-1"}, x509util.TemplateData{
x509util.WebhooksKey: map[string]any{
"webhook-name-1": nil,
},
}, nil},
{"ok/with-data", &SCEP{
Name: "SCEP",
Type: "SCEP",
Options: &Options{
Webhooks: []*Webhook{
{
ID: "webhook-id-1",
Name: "webhook-name-1",
Secret: "MTIzNAo=",
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
CertType: linkedca.Webhook_X509.String(),
URL: okServer.URL,
},
{
ID: "webhook-id-2",
Name: "webhook-name-2",
Secret: "MTIzNAo=",
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
CertType: linkedca.Webhook_X509.String(),
URL: okServer.URL,
},
},
},
}, okServer, args{"webhook-challenge", "webhook-transaction-1"}, x509util.TemplateData{
x509util.WebhooksKey: map[string]any{
"webhook-name-1": nil,
"webhook-name-2": map[string]any{
"ID": "2adcbfec-5e4a-4b93-8913-640e24faf101",
"Email": "admin@example.com",
},
},
}, nil},
{"fail/webhooks-secret-configuration", &SCEP{
Name: "SCEP",
Type: "SCEP",
@@ -317,60 +405,53 @@ func TestSCEP_ValidateChallenge(t *testing.T) {
},
},
},
}, nil, args{"webhook-challenge", "webhook-transaction-1"},
errors.New("failed executing webhook request: illegal base64 data at input byte 0"),
},
}, nil, args{"webhook-challenge", "webhook-transaction-1"}, nil, errors.New("failed executing webhook request: illegal base64 data at input byte 0")},
{"ok/static-challenge", &SCEP{
Name: "SCEP",
Type: "SCEP",
Options: &Options{},
ChallengePassword: "secret-static-challenge",
}, nil, args{"secret-static-challenge", "static-transaction-1"},
nil,
},
}, nil, args{"secret-static-challenge", "static-transaction-1"}, x509util.TemplateData{}, nil},
{"fail/wrong-static-challenge", &SCEP{
Name: "SCEP",
Type: "SCEP",
Options: &Options{},
ChallengePassword: "secret-static-challenge",
}, nil, args{"the-wrong-challenge-secret", "static-transaction-1"},
errors.New("invalid challenge password provided"),
},
}, nil, args{"the-wrong-challenge-secret", "static-transaction-1"}, nil, errors.New("invalid challenge password provided")},
{"ok/no-challenge", &SCEP{
Name: "SCEP",
Type: "SCEP",
Options: &Options{},
ChallengePassword: "",
}, nil, args{"", "static-transaction-1"},
nil,
},
}, nil, args{"", "static-transaction-1"}, x509util.TemplateData{}, nil},
{"fail/no-challenge-but-provided", &SCEP{
Name: "SCEP",
Type: "SCEP",
Options: &Options{},
ChallengePassword: "",
}, nil, args{"a-challenge-value", "static-transaction-1"},
errors.New("invalid challenge password provided"),
},
}, nil, args{"a-challenge-value", "static-transaction-1"}, nil, errors.New("invalid challenge password provided")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.server != nil {
defer tt.server.Close()
}
err := tt.p.Init(Config{Claims: globalProvisionerClaims, WebhookClient: http.DefaultClient})
require.NoError(t, err)
ctx := context.Background()
err = tt.p.ValidateChallenge(ctx, dummyCSR, tt.args.challenge, tt.args.transactionID)
got, err := tt.p.ValidateChallenge(ctx, dummyCSR, tt.args.challenge, tt.args.transactionID)
if tt.expErr != nil {
assert.EqualError(t, err, tt.expErr.Error())
return
}
assert.NoError(t, err)
data := x509util.TemplateData{}
for _, o := range got {
if m, ok := o.(TemplateDataModifier); ok {
m.Modify(data)
} else {
t.Errorf("Validate() got = %T, want TemplateDataModifier", o)
}
}
assert.Equal(t, tt.want, data)
})
}
}

View File

@@ -545,3 +545,28 @@ func (s csrFingerprintValidator) Valid(cr *x509.CertificateRequest) error {
}
return nil
}
// SignCSROption is the interface used to collect extra options in the SignCSR
// method of the SCEP authority.
type SignCSROption any
// TemplateDataModifier is an interface that allows to modify template data.
type TemplateDataModifier interface {
Modify(data x509util.TemplateData)
}
type templateDataModifier struct {
fn func(x509util.TemplateData)
}
func (t *templateDataModifier) Modify(data x509util.TemplateData) {
t.fn(data)
}
// TemplateDataModifierFunc returns a TemplateDataModifier with the given
// function.
func TemplateDataModifierFunc(fn func(data x509util.TemplateData)) TemplateDataModifier {
return &templateDataModifier{
fn: fn,
}
}

View File

@@ -384,14 +384,17 @@ func PKIOperation(ctx context.Context, req request) (Response, error) {
// 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.
var signCSROpts []provisioner.SignCSROption
if msg.MessageType == smallscep.PKCSReq || msg.MessageType == smallscep.RenewalReq {
if err := auth.ValidateChallenge(ctx, csr, challengePassword, transactionID); err != nil {
challengeOptions, err := auth.ValidateChallenge(ctx, csr, challengePassword, transactionID)
if err != nil {
if errors.Is(err, provisioner.ErrSCEPChallengeInvalid) {
return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, err.Error(), err)
}
scepErr := errors.New("failed validating challenge password")
return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, scepErr.Error(), scepErr)
}
signCSROpts = append(signCSROpts, challengeOptions...)
}
// TODO: authorize renewal: we can authorize renewals with the challenge password (if reusable secrets are used).
@@ -402,7 +405,7 @@ func PKIOperation(ctx context.Context, req request) (Response, error) {
// Authentication by the (self-signed) certificate with an optional challenge is required; supporting renewals incl. verification
// of the client cert is not.
certRep, err := auth.SignCSR(ctx, csr, msg)
certRep, err := auth.SignCSR(ctx, csr, msg, signCSROpts...)
if err != nil {
if notifyErr := auth.NotifyFailure(ctx, csr, transactionID, 0, err.Error()); notifyErr != nil {
// TODO(hs): ignore this error case? It's not critical if the notification fails; but logging it might be good

View File

@@ -241,7 +241,7 @@ func (a *Authority) DecryptPKIEnvelope(ctx context.Context, msg *PKIMessage) err
// SignCSR creates an x509.Certificate based on a CSR template and Cert Authority credentials
// returns a new PKIMessage with CertRep data
func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, msg *PKIMessage) (*PKIMessage, error) {
func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, msg *PKIMessage, signCSROpts ...provisioner.SignCSROption) (*PKIMessage, error) {
// TODO: intermediate storage of the request? In SCEP it's possible to request a csr/certificate
// to be signed, which can be performed asynchronously / out-of-band. In that case a client can
// poll for the status. It seems to be similar as what can happen in ACME, so might want to model
@@ -284,6 +284,13 @@ func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, m
CommonName: csr.Subject.CommonName,
})
// Apply CSR options. Currently only one option is defined.
for _, o := range signCSROpts {
if m, ok := o.(provisioner.TemplateDataModifier); ok {
m.Modify(data)
}
}
// Get authorizations from the SCEP provisioner.
ctx = provisioner.NewContextWithMethod(ctx, provisioner.SignMethod)
signOps, err := p.AuthorizeSign(ctx, "")
@@ -506,7 +513,7 @@ func (a *Authority) GetCACaps(ctx context.Context) []string {
return caps
}
func (a *Authority) ValidateChallenge(ctx context.Context, csr *x509.CertificateRequest, challenge, transactionID string) error {
func (a *Authority) ValidateChallenge(ctx context.Context, csr *x509.CertificateRequest, challenge, transactionID string) ([]provisioner.SignCSROption, error) {
p := provisionerFromContext(ctx)
return p.ValidateChallenge(ctx, csr, challenge, transactionID)
}

View File

@@ -1,16 +1,27 @@
package scep
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"net/url"
"testing"
"github.com/smallstep/certificates/authority/config"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/pkcs7"
"github.com/smallstep/scep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/keyutil"
"go.step.sm/crypto/minica"
"go.step.sm/crypto/randutil"
"go.step.sm/crypto/x509util"
"go.step.sm/linkedca"
)
func generateContent(t *testing.T, size int) []byte {
@@ -71,3 +82,168 @@ func TestAuthority_encrypt(t *testing.T) {
})
}
}
type signAuthority struct {
ca *minica.CA
webhooks []*provisioner.Webhook
template string
}
func (s *signAuthority) SignWithContext(ctx context.Context, cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) {
var certOptions []x509util.Option
for _, so := range signOpts {
if co, ok := so.(provisioner.CertificateOptions); ok {
certOptions = append(certOptions, co.Options(opts)...)
}
}
c, err := x509util.NewCertificate(cr, certOptions...)
if err != nil {
return nil, err
}
crt, err := s.ca.Sign(c.GetCertificate())
if err != nil {
return nil, err
}
return []*x509.Certificate{crt, s.ca.Intermediate}, nil
}
func (s *signAuthority) LoadProvisionerByName(string) (provisioner.Interface, error) {
p := &provisioner.SCEP{
Name: "scep",
Type: "SCEP",
ChallengePassword: "password",
DecrypterCertificate: pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: s.ca.Intermediate.Raw,
}),
DecrypterKeyPEM: pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(s.ca.Signer.(*rsa.PrivateKey)),
}),
Options: &provisioner.Options{
Webhooks: s.webhooks,
X509: &provisioner.X509Options{
Template: s.template,
},
},
}
if err := p.Init(provisioner.Config{
Claims: config.GlobalProvisionerClaims,
}); err != nil {
return nil, err
}
return p, nil
}
func TestAuthority_SignCSR(t *testing.T) {
ca, err := minica.New(minica.WithGetSignerFunc(func() (crypto.Signer, error) {
return rsa.GenerateKey(rand.Reader, 2048)
}))
require.NoError(t, err)
sa := &signAuthority{
ca: ca,
webhooks: []*provisioner.Webhook{{
ID: "1f81b7ed-62c4-4dd5-b63a-348e92b2e25d",
Name: "ScepChallenge",
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
CertType: linkedca.Webhook_X509.String(),
URL: "https://not.used",
Secret: "MTIzNAo=",
}},
template: `{
{{- with .Webhooks.ScepChallenge.CommonName }}
"subject": {"commonName" : {{ . | toJson }}},
{{- else }}
"subject": {{ toJson .Subject }},
{{- end }}
{{- with .Webhooks.ScepChallenge.Email }}
"emailAddresses" : [ {{ . | toJson }} ],
{{- else }}
"sans": {{ toJson .SANs }},
{{- end }}
{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
"keyUsage": ["keyEncipherment", "digitalSignature"],
{{- else }}
"keyUsage": ["digitalSignature"],
{{- end }}
"extKeyUsage": ["serverAuth", "clientAuth"]
}`,
}
a1, err := New(sa, Options{
Roots: []*x509.Certificate{ca.Root},
Intermediates: []*x509.Certificate{ca.Intermediate},
SignerCert: ca.Intermediate,
Signer: ca.Signer,
Decrypter: ca.Signer.(*rsa.PrivateKey),
DecrypterCert: ca.Intermediate,
SCEPProvisionerNames: []string{"scep"},
})
require.NoError(t, err)
p1, err := a1.LoadProvisionerByName("scep")
require.NoError(t, err)
ctx := NewProvisionerContext(context.Background(), p1.(*provisioner.SCEP))
signer, err := keyutil.GenerateDefaultSigner()
require.NoError(t, err)
csr, err := x509util.CreateCertificateRequest("jane@example.com", []string{"urn:uuid:81d19787-cfe8-4a04-82b2-8827f3727235"}, signer)
require.NoError(t, err)
type args struct {
ctx context.Context
csr *x509.CertificateRequest
msg *PKIMessage
signCSROpts []provisioner.SignCSROption
}
tests := []struct {
name string
authority *Authority
args args
validate func(*testing.T, *PKIMessage)
assertion assert.ErrorAssertionFunc
}{
{"ok", a1, args{ctx, csr, &PKIMessage{
CSRReqMessage: &scep.CSRReqMessage{CSR: csr},
P7: &pkcs7.PKCS7{
Certificates: []*x509.Certificate{ca.Intermediate},
},
}, []provisioner.SignCSROption{
provisioner.TemplateDataModifierFunc(func(data x509util.TemplateData) {
data.SetWebhook("ScepChallenge", map[string]any{
"CommonName": "Jane C.",
"Email": "jane@example.com",
})
}),
}}, func(t *testing.T, p *PKIMessage) {
require.NotNil(t, p.CertRepMessage)
cert, err := x509.ParseCertificate(p.Certificate.Raw)
require.NoError(t, err)
assert.Equal(t, "Jane C.", cert.Subject.CommonName)
assert.Equal(t, []string{"jane@example.com"}, cert.EmailAddresses)
assert.Nil(t, cert.URIs)
}, assert.NoError},
{"ok no sign options", a1, args{ctx, csr, &PKIMessage{
CSRReqMessage: &scep.CSRReqMessage{CSR: csr},
P7: &pkcs7.PKCS7{
Certificates: []*x509.Certificate{ca.Intermediate},
},
}, []provisioner.SignCSROption{}}, func(t *testing.T, p *PKIMessage) {
require.NotNil(t, p.CertRepMessage)
cert, err := x509.ParseCertificate(p.Certificate.Raw)
require.NoError(t, err)
assert.Equal(t, "jane@example.com", cert.Subject.CommonName)
assert.Nil(t, cert.EmailAddresses)
assert.Equal(t, []*url.URL{{Scheme: "urn", Opaque: "uuid:81d19787-cfe8-4a04-82b2-8827f3727235"}}, cert.URIs)
}, assert.NoError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.authority.SignCSR(tt.args.ctx, tt.args.csr, tt.args.msg, tt.args.signCSROpts...)
tt.assertion(t, err)
tt.validate(t, got)
})
}
}

View File

@@ -20,7 +20,7 @@ type Provisioner interface {
GetDecrypter() (*x509.Certificate, crypto.Decrypter)
GetSigner() (*x509.Certificate, crypto.Signer)
GetContentEncryptionAlgorithm() int
ValidateChallenge(ctx context.Context, csr *x509.CertificateRequest, challenge, transactionID string) error
ValidateChallenge(ctx context.Context, csr *x509.CertificateRequest, challenge, transactionID string) ([]provisioner.SignCSROption, error)
NotifySuccess(ctx context.Context, csr *x509.CertificateRequest, cert *x509.Certificate, transactionID string) error
NotifyFailure(ctx context.Context, csr *x509.CertificateRequest, transactionID string, errorCode int, errorDescription string) error
}