forked from TrueCloudLab/certificates
Merge pull request #1366 from smallstep/herman/dynamic-scep-webhook
Dynamic SCEP challenge validation using webhooks
This commit is contained in:
commit
cb1dc8055d
10 changed files with 547 additions and 75 deletions
|
@ -57,9 +57,9 @@ func validateWebhook(webhook *linkedca.Webhook) error {
|
||||||
|
|
||||||
// kind
|
// kind
|
||||||
switch webhook.Kind {
|
switch webhook.Kind {
|
||||||
case linkedca.Webhook_ENRICHING, linkedca.Webhook_AUTHORIZING:
|
case linkedca.Webhook_ENRICHING, linkedca.Webhook_AUTHORIZING, linkedca.Webhook_SCEPCHALLENGE:
|
||||||
default:
|
default:
|
||||||
return admin.NewError(admin.ErrorBadRequestType, "webhook kind is invalid")
|
return admin.NewError(admin.ErrorBadRequestType, "webhook kind %q is invalid", webhook.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -180,6 +180,26 @@ func TestWebhookAdminResponder_CreateProvisionerWebhook(t *testing.T) {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fail/unsupported-webhook-kind": func(t *testing.T) test {
|
||||||
|
prov := &linkedca.Provisioner{
|
||||||
|
Name: "provName",
|
||||||
|
}
|
||||||
|
ctx := linkedca.NewContextWithProvisioner(context.Background(), prov)
|
||||||
|
adminErr := admin.NewError(admin.ErrorBadRequestType, `(line 5:13): invalid value for enum type: "UNSUPPORTED"`)
|
||||||
|
adminErr.Message = `(line 5:13): invalid value for enum type: "UNSUPPORTED"`
|
||||||
|
body := []byte(`
|
||||||
|
{
|
||||||
|
"name": "metadata",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"kind": "UNSUPPORTED",
|
||||||
|
}`)
|
||||||
|
return test{
|
||||||
|
ctx: ctx,
|
||||||
|
body: body,
|
||||||
|
err: adminErr,
|
||||||
|
statusCode: 400,
|
||||||
|
}
|
||||||
|
},
|
||||||
"fail/auth.UpdateProvisioner-error": func(t *testing.T) test {
|
"fail/auth.UpdateProvisioner-error": func(t *testing.T) test {
|
||||||
adm := &linkedca.Admin{
|
adm := &linkedca.Admin{
|
||||||
Subject: "step",
|
Subject: "step",
|
||||||
|
|
|
@ -545,50 +545,6 @@ func (a *Authority) init() error {
|
||||||
tmplVars.SSH.UserFederatedKeys = append(tmplVars.SSH.UserFederatedKeys, a.sshCAUserFederatedCerts...)
|
tmplVars.SSH.UserFederatedKeys = append(tmplVars.SSH.UserFederatedKeys, a.sshCAUserFederatedCerts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a KMS with decryption capability is required and available
|
|
||||||
if a.requiresDecrypter() {
|
|
||||||
if _, ok := a.keyManager.(kmsapi.Decrypter); !ok {
|
|
||||||
return errors.New("keymanager doesn't provide crypto.Decrypter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: decide if this is a good approach for providing the SCEP functionality
|
|
||||||
// It currently mirrors the logic for the x509CAService
|
|
||||||
if a.requiresSCEPService() && a.scepService == nil {
|
|
||||||
var options scep.Options
|
|
||||||
|
|
||||||
// Read intermediate and create X509 signer and decrypter for default CAS.
|
|
||||||
options.CertificateChain, err = pemutil.ReadCertificateBundle(a.config.IntermediateCert)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
options.CertificateChain = append(options.CertificateChain, a.rootX509Certs...)
|
|
||||||
options.Signer, err = a.keyManager.CreateSigner(&kmsapi.CreateSignerRequest{
|
|
||||||
SigningKey: a.config.IntermediateKey,
|
|
||||||
Password: a.password,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if km, ok := a.keyManager.(kmsapi.Decrypter); ok {
|
|
||||||
options.Decrypter, err = km.CreateDecrypter(&kmsapi.CreateDecrypterRequest{
|
|
||||||
DecryptionKey: a.config.IntermediateKey,
|
|
||||||
Password: a.password,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.scepService, err = scep.NewService(ctx, options)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: mimick the x509CAService GetCertificateAuthority here too?
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.config.AuthorityConfig.EnableAdmin {
|
if a.config.AuthorityConfig.EnableAdmin {
|
||||||
// Initialize step-ca Admin Database if it's not already initialized using
|
// Initialize step-ca Admin Database if it's not already initialized using
|
||||||
// WithAdminDB.
|
// WithAdminDB.
|
||||||
|
@ -684,6 +640,50 @@ func (a *Authority) init() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if a KMS with decryption capability is required and available
|
||||||
|
if a.requiresDecrypter() {
|
||||||
|
if _, ok := a.keyManager.(kmsapi.Decrypter); !ok {
|
||||||
|
return errors.New("keymanager doesn't provide crypto.Decrypter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: decide if this is a good approach for providing the SCEP functionality
|
||||||
|
// It currently mirrors the logic for the x509CAService
|
||||||
|
if a.requiresSCEPService() && a.scepService == nil {
|
||||||
|
var options scep.Options
|
||||||
|
|
||||||
|
// Read intermediate and create X509 signer and decrypter for default CAS.
|
||||||
|
options.CertificateChain, err = pemutil.ReadCertificateBundle(a.config.IntermediateCert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.CertificateChain = append(options.CertificateChain, a.rootX509Certs...)
|
||||||
|
options.Signer, err = a.keyManager.CreateSigner(&kmsapi.CreateSignerRequest{
|
||||||
|
SigningKey: a.config.IntermediateKey,
|
||||||
|
Password: a.password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if km, ok := a.keyManager.(kmsapi.Decrypter); ok {
|
||||||
|
options.Decrypter, err = km.CreateDecrypter(&kmsapi.CreateDecrypterRequest{
|
||||||
|
DecryptionKey: a.config.IntermediateKey,
|
||||||
|
Password: a.password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.scepService, err = scep.NewService(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: mimick the x509CAService GetCertificateAuthority here too?
|
||||||
|
}
|
||||||
|
|
||||||
// Load X509 constraints engine.
|
// Load X509 constraints engine.
|
||||||
//
|
//
|
||||||
// This is currently only available in CA mode.
|
// This is currently only available in CA mode.
|
||||||
|
|
|
@ -2,10 +2,16 @@ package provisioner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"go.step.sm/linkedca"
|
"go.step.sm/linkedca"
|
||||||
|
|
||||||
|
"github.com/smallstep/certificates/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SCEP is the SCEP provisioner type, an entity that can authorize the
|
// SCEP is the SCEP provisioner type, an entity that can authorize the
|
||||||
|
@ -35,6 +41,7 @@ type SCEP struct {
|
||||||
ctl *Controller
|
ctl *Controller
|
||||||
secretChallengePassword string
|
secretChallengePassword string
|
||||||
encryptionAlgorithm int
|
encryptionAlgorithm int
|
||||||
|
challengeValidationController *challengeValidationController
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID returns the provisioner unique identifier.
|
// GetID returns the provisioner unique identifier.
|
||||||
|
@ -82,6 +89,67 @@ func (s *SCEP) DefaultTLSCertDuration() time.Duration {
|
||||||
return s.ctl.Claimer.DefaultTLSCertDuration()
|
return s.ctl.Claimer.DefaultTLSCertDuration()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type challengeValidationController struct {
|
||||||
|
client *http.Client
|
||||||
|
webhooks []*Webhook
|
||||||
|
}
|
||||||
|
|
||||||
|
// newChallengeValidationController creates a new challengeValidationController
|
||||||
|
// that performs challenge validation through webhooks.
|
||||||
|
func newChallengeValidationController(client *http.Client, webhooks []*Webhook) *challengeValidationController {
|
||||||
|
scepHooks := []*Webhook{}
|
||||||
|
for _, wh := range webhooks {
|
||||||
|
if wh.Kind != linkedca.Webhook_SCEPCHALLENGE.String() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isCertTypeOK(wh) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scepHooks = append(scepHooks, wh)
|
||||||
|
}
|
||||||
|
return &challengeValidationController{
|
||||||
|
client: client,
|
||||||
|
webhooks: scepHooks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSCEPChallengeInvalid = errors.New("webhook server did not allow request")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate executes zero or more configured webhooks to
|
||||||
|
// validate the SCEP challenge. If at least one of them indicates
|
||||||
|
// the challenge value is accepted, validation succeeds. In
|
||||||
|
// 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, challenge, transactionID string) error {
|
||||||
|
for _, wh := range c.webhooks {
|
||||||
|
req := &webhook.RequestBody{
|
||||||
|
SCEPChallenge: challenge,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if resp.Allow {
|
||||||
|
return nil // return early when response is positive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ErrSCEPChallengeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCertTypeOK returns whether or not the webhook can be used
|
||||||
|
// with the SCEP challenge validation webhook controller.
|
||||||
|
func isCertTypeOK(wh *Webhook) bool {
|
||||||
|
if wh.CertType == linkedca.Webhook_ALL.String() || wh.CertType == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return linkedca.Webhook_X509.String() == wh.CertType
|
||||||
|
}
|
||||||
|
|
||||||
// Init initializes and validates the fields of a SCEP type.
|
// Init initializes and validates the fields of a SCEP type.
|
||||||
func (s *SCEP) Init(config Config) (err error) {
|
func (s *SCEP) Init(config Config) (err error) {
|
||||||
switch {
|
switch {
|
||||||
|
@ -109,6 +177,11 @@ func (s *SCEP) Init(config Config) (err error) {
|
||||||
return errors.New("only encryption algorithm identifiers from 0 to 4 are valid")
|
return errors.New("only encryption algorithm identifiers from 0 to 4 are valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.challengeValidationController = newChallengeValidationController(
|
||||||
|
config.WebhookClient,
|
||||||
|
s.GetOptions().GetWebhooks(),
|
||||||
|
)
|
||||||
|
|
||||||
// TODO: add other, SCEP specific, options?
|
// TODO: add other, SCEP specific, options?
|
||||||
|
|
||||||
s.ctl, err = NewController(s, s.Claims, config, s.Options)
|
s.ctl, err = NewController(s, s.Claims, config, s.Options)
|
||||||
|
@ -156,3 +229,43 @@ func (s *SCEP) ShouldIncludeRootInChain() bool {
|
||||||
func (s *SCEP) GetContentEncryptionAlgorithm() int {
|
func (s *SCEP) GetContentEncryptionAlgorithm() int {
|
||||||
return s.encryptionAlgorithm
|
return s.encryptionAlgorithm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, challenge, transactionID string) error {
|
||||||
|
if s.challengeValidationController == nil {
|
||||||
|
return fmt.Errorf("provisioner %q wasn't initialized", s.Name)
|
||||||
|
}
|
||||||
|
switch s.selectValidationMethod() {
|
||||||
|
case validationMethodWebhook:
|
||||||
|
return s.challengeValidationController.Validate(ctx, challenge, transactionID)
|
||||||
|
default:
|
||||||
|
if subtle.ConstantTimeCompare([]byte(s.secretChallengePassword), []byte(challenge)) == 0 {
|
||||||
|
return errors.New("invalid challenge password provided")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
validationMethodNone validationMethod = "none"
|
||||||
|
validationMethodStatic validationMethod = "static"
|
||||||
|
validationMethodWebhook validationMethod = "webhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
// selectValidationMethod returns the method to validate SCEP
|
||||||
|
// challenges. If a webhook is configured with kind `SCEPCHALLENGE`,
|
||||||
|
// the webhook method will be used. If a challenge password is set,
|
||||||
|
// the static method is used. It will default to the `none` method.
|
||||||
|
func (s *SCEP) selectValidationMethod() validationMethod {
|
||||||
|
if len(s.challengeValidationController.webhooks) > 0 {
|
||||||
|
return validationMethodWebhook
|
||||||
|
}
|
||||||
|
if s.secretChallengePassword != "" {
|
||||||
|
return validationMethodStatic
|
||||||
|
}
|
||||||
|
return validationMethodNone
|
||||||
|
}
|
||||||
|
|
342
authority/provisioner/scep_test.go
Normal file
342
authority/provisioner/scep_test.go
Normal file
|
@ -0,0 +1,342 @@
|
||||||
|
package provisioner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"go.step.sm/linkedca"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_challengeValidationController_Validate(t *testing.T) {
|
||||||
|
type request struct {
|
||||||
|
Challenge string `json:"scepChallenge"`
|
||||||
|
TransactionID string `json:"scepTransactionID"`
|
||||||
|
}
|
||||||
|
type response struct {
|
||||||
|
Allow bool `json:"allow"`
|
||||||
|
}
|
||||||
|
nokServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req := &request{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "not-allowed", req.Challenge)
|
||||||
|
assert.Equal(t, "transaction-1", req.TransactionID)
|
||||||
|
b, err := json.Marshal(response{Allow: false})
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(b)
|
||||||
|
}))
|
||||||
|
okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req := &request{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "challenge", req.Challenge)
|
||||||
|
assert.Equal(t, "transaction-1", req.TransactionID)
|
||||||
|
b, err := json.Marshal(response{Allow: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(b)
|
||||||
|
}))
|
||||||
|
type fields struct {
|
||||||
|
client *http.Client
|
||||||
|
webhooks []*Webhook
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
challenge string
|
||||||
|
transactionID string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
server *httptest.Server
|
||||||
|
expErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fail/no-webhook",
|
||||||
|
fields: fields{http.DefaultClient, nil},
|
||||||
|
args: args{"no-webhook", "transaction-1"},
|
||||||
|
expErr: errors.New("webhook server did not allow request"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fail/wrong-cert-type",
|
||||||
|
fields: fields{http.DefaultClient, []*Webhook{
|
||||||
|
{
|
||||||
|
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
|
||||||
|
CertType: linkedca.Webhook_SSH.String(),
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
args: args{"wrong-cert-type", "transaction-1"},
|
||||||
|
expErr: errors.New("webhook server did not allow request"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fail/wrong-secret-value",
|
||||||
|
fields: fields{http.DefaultClient, []*Webhook{
|
||||||
|
{
|
||||||
|
ID: "webhook-id-1",
|
||||||
|
Name: "webhook-name-1",
|
||||||
|
Secret: "{{}}",
|
||||||
|
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
|
||||||
|
CertType: linkedca.Webhook_X509.String(),
|
||||||
|
URL: okServer.URL,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
args: args{
|
||||||
|
challenge: "wrong-secret-value",
|
||||||
|
transactionID: "transaction-1",
|
||||||
|
},
|
||||||
|
expErr: errors.New("failed executing webhook request: illegal base64 data at input byte 0"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fail/not-allowed",
|
||||||
|
fields: fields{http.DefaultClient, []*Webhook{
|
||||||
|
{
|
||||||
|
ID: "webhook-id-1",
|
||||||
|
Name: "webhook-name-1",
|
||||||
|
Secret: "MTIzNAo=",
|
||||||
|
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
|
||||||
|
CertType: linkedca.Webhook_X509.String(),
|
||||||
|
URL: nokServer.URL,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
args: args{
|
||||||
|
challenge: "not-allowed",
|
||||||
|
transactionID: "transaction-1",
|
||||||
|
},
|
||||||
|
server: nokServer,
|
||||||
|
expErr: errors.New("webhook server did not allow request"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ok",
|
||||||
|
fields: fields{http.DefaultClient, []*Webhook{
|
||||||
|
{
|
||||||
|
ID: "webhook-id-1",
|
||||||
|
Name: "webhook-name-1",
|
||||||
|
Secret: "MTIzNAo=",
|
||||||
|
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
|
||||||
|
CertType: linkedca.Webhook_X509.String(),
|
||||||
|
URL: okServer.URL,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
args: args{
|
||||||
|
challenge: "challenge",
|
||||||
|
transactionID: "transaction-1",
|
||||||
|
},
|
||||||
|
server: okServer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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, tt.args.challenge, tt.args.transactionID)
|
||||||
|
|
||||||
|
if tt.expErr != nil {
|
||||||
|
assert.EqualError(t, err, tt.expErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestController_isCertTypeOK(t *testing.T) {
|
||||||
|
assert.True(t, isCertTypeOK(&Webhook{CertType: linkedca.Webhook_X509.String()}))
|
||||||
|
assert.True(t, isCertTypeOK(&Webhook{CertType: linkedca.Webhook_ALL.String()}))
|
||||||
|
assert.True(t, isCertTypeOK(&Webhook{CertType: ""}))
|
||||||
|
assert.False(t, isCertTypeOK(&Webhook{CertType: linkedca.Webhook_SSH.String()}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_selectValidationMethod(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
p *SCEP
|
||||||
|
want validationMethod
|
||||||
|
}{
|
||||||
|
{"webhooks", &SCEP{
|
||||||
|
Name: "SCEP",
|
||||||
|
Type: "SCEP",
|
||||||
|
Options: &Options{
|
||||||
|
Webhooks: []*Webhook{
|
||||||
|
{
|
||||||
|
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, "webhook"},
|
||||||
|
{"challenge", &SCEP{
|
||||||
|
Name: "SCEP",
|
||||||
|
Type: "SCEP",
|
||||||
|
ChallengePassword: "pass",
|
||||||
|
}, "static"},
|
||||||
|
{"challenge-with-different-webhook", &SCEP{
|
||||||
|
Name: "SCEP",
|
||||||
|
Type: "SCEP",
|
||||||
|
Options: &Options{
|
||||||
|
Webhooks: []*Webhook{
|
||||||
|
{
|
||||||
|
Kind: linkedca.Webhook_AUTHORIZING.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ChallengePassword: "pass",
|
||||||
|
}, "static"},
|
||||||
|
{"none", &SCEP{
|
||||||
|
Name: "SCEP",
|
||||||
|
Type: "SCEP",
|
||||||
|
}, "none"},
|
||||||
|
{"none-with-different-webhook", &SCEP{
|
||||||
|
Name: "SCEP",
|
||||||
|
Type: "SCEP",
|
||||||
|
Options: &Options{
|
||||||
|
Webhooks: []*Webhook{
|
||||||
|
{
|
||||||
|
Kind: linkedca.Webhook_AUTHORIZING.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, "none"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.p.Init(Config{Claims: globalProvisionerClaims})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := tt.p.selectValidationMethod()
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_ValidateChallenge(t *testing.T) {
|
||||||
|
type request struct {
|
||||||
|
Challenge string `json:"scepChallenge"`
|
||||||
|
TransactionID string `json:"scepTransactionID"`
|
||||||
|
}
|
||||||
|
type response struct {
|
||||||
|
Allow bool `json:"allow"`
|
||||||
|
}
|
||||||
|
okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req := &request{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "webhook-challenge", req.Challenge)
|
||||||
|
assert.Equal(t, "webhook-transaction-1", req.TransactionID)
|
||||||
|
b, err := json.Marshal(response{Allow: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(b)
|
||||||
|
}))
|
||||||
|
type args struct {
|
||||||
|
challenge string
|
||||||
|
transactionID string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
p *SCEP
|
||||||
|
server *httptest.Server
|
||||||
|
args args
|
||||||
|
expErr error
|
||||||
|
}{
|
||||||
|
{"ok/webhooks", &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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, okServer, args{"webhook-challenge", "webhook-transaction-1"},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{"fail/webhooks-secret-configuration", &SCEP{
|
||||||
|
Name: "SCEP",
|
||||||
|
Type: "SCEP",
|
||||||
|
Options: &Options{
|
||||||
|
Webhooks: []*Webhook{
|
||||||
|
{
|
||||||
|
ID: "webhook-id-1",
|
||||||
|
Name: "webhook-name-1",
|
||||||
|
Secret: "{{}}",
|
||||||
|
Kind: linkedca.Webhook_SCEPCHALLENGE.String(),
|
||||||
|
CertType: linkedca.Webhook_X509.String(),
|
||||||
|
URL: okServer.URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil, args{"webhook-challenge", "webhook-transaction-1"},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{"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"),
|
||||||
|
},
|
||||||
|
{"ok/no-challenge", &SCEP{
|
||||||
|
Name: "SCEP",
|
||||||
|
Type: "SCEP",
|
||||||
|
Options: &Options{},
|
||||||
|
ChallengePassword: "",
|
||||||
|
}, nil, args{"", "static-transaction-1"},
|
||||||
|
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"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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, tt.args.challenge, tt.args.transactionID)
|
||||||
|
if tt.expErr != nil {
|
||||||
|
assert.EqualError(t, err, tt.expErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -107,6 +107,13 @@ type Webhook struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Webhook) Do(client *http.Client, reqBody *webhook.RequestBody, data any) (*webhook.ResponseBody, error) {
|
func (w *Webhook) Do(client *http.Client, reqBody *webhook.RequestBody, data any) (*webhook.ResponseBody, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return w.DoWithContext(ctx, client, reqBody, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Webhook) DoWithContext(ctx context.Context, client *http.Client, reqBody *webhook.RequestBody, data any) (*webhook.ResponseBody, error) {
|
||||||
tmpl, err := template.New("url").Funcs(templates.StepFuncMap()).Parse(w.URL)
|
tmpl, err := template.New("url").Funcs(templates.StepFuncMap()).Parse(w.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -129,8 +136,6 @@ func (w *Webhook) Do(client *http.Client, reqBody *webhook.RequestBody, data any
|
||||||
reqBody.Token = tmpl[sshutil.TokenKey]
|
reqBody.Token = tmpl[sshutil.TokenKey]
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
reqBody.Timestamp = time.Now()
|
reqBody.Timestamp = time.Now()
|
||||||
|
|
||||||
|
|
|
@ -305,6 +305,8 @@ func PKIOperation(ctx context.Context, req request) (Response, error) {
|
||||||
|
|
||||||
// NOTE: at this point we have sufficient information for returning nicely signed CertReps
|
// NOTE: at this point we have sufficient information for returning nicely signed CertReps
|
||||||
csr := msg.CSRReqMessage.CSR
|
csr := msg.CSRReqMessage.CSR
|
||||||
|
transactionID := string(msg.TransactionID)
|
||||||
|
challengePassword := msg.CSRReqMessage.ChallengePassword
|
||||||
|
|
||||||
// NOTE: we're blocking the RenewalReq if the challenge does not match, because otherwise we don't have any authentication.
|
// 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,
|
// The macOS SCEP client performs renewals using PKCSreq. The CertNanny SCEP client will use PKCSreq with challenge too, it seems,
|
||||||
|
@ -312,13 +314,11 @@ func PKIOperation(ctx context.Context, req request) (Response, error) {
|
||||||
// a certificate exists; then it will use RenewalReq. Adding the challenge check here may be a small breaking change for clients.
|
// 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.
|
// We'll have to see how it works out.
|
||||||
if msg.MessageType == microscep.PKCSReq || msg.MessageType == microscep.RenewalReq {
|
if msg.MessageType == microscep.PKCSReq || msg.MessageType == microscep.RenewalReq {
|
||||||
challengeMatches, err := auth.MatchChallengePassword(ctx, msg.CSRReqMessage.ChallengePassword)
|
if err := auth.ValidateChallenge(ctx, challengePassword, transactionID); err != nil {
|
||||||
if err != nil {
|
if errors.Is(err, provisioner.ErrSCEPChallengeInvalid) {
|
||||||
return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("error when checking password"))
|
return createFailureResponse(ctx, csr, msg, microscep.BadRequest, err)
|
||||||
}
|
}
|
||||||
if !challengeMatches {
|
return createFailureResponse(ctx, csr, msg, microscep.BadRequest, errors.New("failed validating challenge password"))
|
||||||
// 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"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ package scep
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -456,24 +455,6 @@ func (a *Authority) CreateFailureResponse(ctx context.Context, csr *x509.Certifi
|
||||||
return crepMsg, nil
|
return crepMsg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchChallengePassword verifies a SCEP challenge password
|
|
||||||
func (a *Authority) MatchChallengePassword(ctx context.Context, password string) (bool, error) {
|
|
||||||
p, err := provisionerFromContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if subtle.ConstantTimeCompare([]byte(p.GetChallengePassword()), []byte(password)) == 1 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: support dynamic challenges, i.e. a list of challenges instead of one?
|
|
||||||
// That's probably a bit harder to configure, though; likely requires some data store
|
|
||||||
// that can be interacted with more easily, via some internal API, for example.
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCACaps returns the CA capabilities
|
// GetCACaps returns the CA capabilities
|
||||||
func (a *Authority) GetCACaps(ctx context.Context) []string {
|
func (a *Authority) GetCACaps(ctx context.Context) []string {
|
||||||
p, err := provisionerFromContext(ctx)
|
p, err := provisionerFromContext(ctx)
|
||||||
|
@ -494,3 +475,11 @@ func (a *Authority) GetCACaps(ctx context.Context) []string {
|
||||||
|
|
||||||
return caps
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Authority) ValidateChallenge(ctx context.Context, challenge, transactionID string) error {
|
||||||
|
p, err := provisionerFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.ValidateChallenge(ctx, challenge, transactionID)
|
||||||
|
}
|
||||||
|
|
|
@ -14,8 +14,8 @@ type Provisioner interface {
|
||||||
GetName() string
|
GetName() string
|
||||||
DefaultTLSCertDuration() time.Duration
|
DefaultTLSCertDuration() time.Duration
|
||||||
GetOptions() *provisioner.Options
|
GetOptions() *provisioner.Options
|
||||||
GetChallengePassword() string
|
|
||||||
GetCapabilities() []string
|
GetCapabilities() []string
|
||||||
ShouldIncludeRootInChain() bool
|
ShouldIncludeRootInChain() bool
|
||||||
GetContentEncryptionAlgorithm() int
|
GetContentEncryptionAlgorithm() int
|
||||||
|
ValidateChallenge(ctx context.Context, challenge, transactionID string) error
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,4 +68,7 @@ type RequestBody struct {
|
||||||
X509Certificate *X509Certificate `json:"x509Certificate,omitempty"`
|
X509Certificate *X509Certificate `json:"x509Certificate,omitempty"`
|
||||||
SSHCertificateRequest *SSHCertificateRequest `json:"sshCertificateRequest,omitempty"`
|
SSHCertificateRequest *SSHCertificateRequest `json:"sshCertificateRequest,omitempty"`
|
||||||
SSHCertificate *SSHCertificate `json:"sshCertificate,omitempty"`
|
SSHCertificate *SSHCertificate `json:"sshCertificate,omitempty"`
|
||||||
|
// Only set for SCEP challenge validation requests
|
||||||
|
SCEPChallenge string `json:"scepChallenge,omitempty"`
|
||||||
|
SCEPTransactionID string `json:"scepTransactionID,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue