Add backend support for Smallstep CA (#32872)

Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com>
Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
This commit is contained in:
Sarah Gillespie
2025-09-25 10:03:36 -05:00
committed by GitHub
co-authored by Jordan Montgomery Magnus Jensen
parent d20ade7947
commit 128a71eb4f
31 changed files with 2373 additions and 129 deletions
+228 -19
View File
@@ -59,9 +59,7 @@ func (svc *Service) NewCertificateAuthority(ctx context.Context, p fleet.Certifi
caDisplayType := "Unknown"
if p.DigiCert != nil {
p.DigiCert.Name = fleet.Preprocess(p.DigiCert.Name)
p.DigiCert.URL = fleet.Preprocess(p.DigiCert.URL)
p.DigiCert.ProfileID = fleet.Preprocess(p.DigiCert.ProfileID)
p.DigiCert.Preprocess()
if err := svc.validateDigicert(ctx, p.DigiCert, errPrefix); err != nil {
return nil, err
}
@@ -78,8 +76,8 @@ func (svc *Service) NewCertificateAuthority(ctx context.Context, p fleet.Certifi
}
if p.Hydrant != nil {
p.Hydrant.Name = fleet.Preprocess(p.Hydrant.Name)
p.Hydrant.URL = fleet.Preprocess(p.Hydrant.URL)
p.Hydrant.Preprocess()
if err := svc.validateHydrant(ctx, p.Hydrant, errPrefix); err != nil {
return nil, err
}
@@ -94,9 +92,8 @@ func (svc *Service) NewCertificateAuthority(ctx context.Context, p fleet.Certifi
}
if p.NDESSCEPProxy != nil {
p.NDESSCEPProxy.URL = fleet.Preprocess(p.NDESSCEPProxy.URL)
p.NDESSCEPProxy.AdminURL = fleet.Preprocess(p.NDESSCEPProxy.AdminURL)
p.NDESSCEPProxy.Username = fleet.Preprocess(p.NDESSCEPProxy.Username)
p.NDESSCEPProxy.Preprocess()
if err := svc.validateNDESSCEPProxy(ctx, p.NDESSCEPProxy, errPrefix); err != nil {
return nil, err
}
@@ -112,8 +109,7 @@ func (svc *Service) NewCertificateAuthority(ctx context.Context, p fleet.Certifi
}
if p.CustomSCEPProxy != nil {
p.CustomSCEPProxy.Name = fleet.Preprocess(p.CustomSCEPProxy.Name)
p.CustomSCEPProxy.URL = fleet.Preprocess(p.CustomSCEPProxy.URL)
p.CustomSCEPProxy.Preprocess()
if err := svc.validateCustomSCEPProxy(ctx, p.CustomSCEPProxy, errPrefix); err != nil {
return nil, err
@@ -127,6 +123,23 @@ func (svc *Service) NewCertificateAuthority(ctx context.Context, p fleet.Certifi
activity = fleet.ActivityAddedCustomSCEPProxy{Name: p.CustomSCEPProxy.Name}
}
if p.Smallstep != nil {
p.Smallstep.Preprocess()
if err := svc.validateSmallstepSCEPProxy(ctx, p.Smallstep, errPrefix); err != nil {
return nil, err
}
caToCreate.Type = string(fleet.CATypeSmallstep)
caToCreate.Name = &p.Smallstep.Name
caToCreate.URL = &p.Smallstep.URL
caToCreate.ChallengeURL = &p.Smallstep.ChallengeURL
caToCreate.Username = &p.Smallstep.Username
caToCreate.Password = &p.Smallstep.Password
caDisplayType = "Smallstep"
activity = fleet.ActivityAddedSmallstep{Name: p.Smallstep.Name}
}
createdCA, err := svc.ds.NewCertificateAuthority(ctx, caToCreate)
if err != nil {
if errors.As(err, &fleet.ConflictError{}) {
@@ -159,6 +172,9 @@ func (svc *Service) validatePayload(p *fleet.CertificateAuthorityPayload, errPre
if p.CustomSCEPProxy != nil {
casToCreate++
}
if p.Smallstep != nil {
casToCreate++
}
if casToCreate == 0 {
return &fleet.BadRequestError{Message: fmt.Sprintf("%sA certificate authority must be specified", errPrefix)}
}
@@ -349,6 +365,30 @@ func (svc *Service) validateCustomSCEPProxy(ctx context.Context, customSCEP *fle
return nil
}
func (svc *Service) validateSmallstepSCEPProxy(ctx context.Context, smallstepSCEP *fleet.SmallstepSCEPProxyCA, errPrefix string) error {
if err := validateCAName(smallstepSCEP.Name, errPrefix); err != nil {
return err
}
if err := validateURL(smallstepSCEP.URL, "Smallstep SCEP", errPrefix); err != nil {
return err
}
if smallstepSCEP.Username == "" {
return fleet.NewInvalidArgumentError("username", fmt.Sprintf("%sSmallstep username cannot be empty", errPrefix))
}
if smallstepSCEP.Password == "" || smallstepSCEP.Password == fleet.MaskedPassword {
return fleet.NewInvalidArgumentError("password", fmt.Sprintf("%sSmallstep password cannot be empty", errPrefix))
}
if err := svc.scepConfigService.ValidateSCEPURL(ctx, smallstepSCEP.URL); err != nil {
level.Error(svc.logger).Log("msg", "Failed to validate Smallstep SCEP URL", "err", err)
return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid SCEP URL. Please correct and try again.", errPrefix)}
}
if err := svc.scepConfigService.ValidateSmallstepChallengeURL(ctx, *smallstepSCEP); err != nil {
level.Error(svc.logger).Log("msg", "Failed to validate Smallstep SCEP admin URL", "err", err)
return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid challenge URL or credentials. Please correct and try again.", errPrefix)}
}
return nil
}
type oauthIntrospectionResponse struct {
Username *string `json:"username"`
// Only active is required in the body by the spec
@@ -381,6 +421,10 @@ func (svc *Service) DeleteCertificateAuthority(ctx context.Context, certificateA
activity = fleet.ActivityDeletedHydrant{
Name: ca.Name,
}
case string(fleet.CATypeSmallstep):
activity = fleet.ActivityDeletedSmallstep{
Name: ca.Name,
}
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil {
@@ -456,9 +500,8 @@ func (svc *Service) getCertificateAuthoritiesBatchOperations(ctx context.Context
if ca.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "certificate_authorities.digicert: CA name cannot be empty.")
}
ca.Name = fleet.Preprocess(ca.Name)
ca.URL = fleet.Preprocess(ca.URL)
ca.ProfileID = fleet.Preprocess(ca.ProfileID)
ca.Preprocess()
if err := checkAllNames(ca.Name, "digicert", "DigiCert"); err != nil {
return nil, err
}
@@ -468,7 +511,8 @@ func (svc *Service) getCertificateAuthoritiesBatchOperations(ctx context.Context
if ca.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "certificate_authorities.custom_scep_proxy: CA name cannot be empty.")
}
ca.Name = fleet.Preprocess(ca.Name)
ca.Preprocess()
if err := checkAllNames(ca.Name, "custom_scep_proxy", "Custom SCEP Proxy"); err != nil {
return nil, err
}
@@ -478,17 +522,26 @@ func (svc *Service) getCertificateAuthoritiesBatchOperations(ctx context.Context
if ca.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "certificate_authorities.hydrant: CA name cannot be empty.")
}
ca.Name = fleet.Preprocess(ca.Name)
ca.URL = fleet.Preprocess(ca.URL)
ca.Preprocess()
if err := checkAllNames(ca.Name, "hydrant", "Hydrant"); err != nil {
return nil, err
}
}
// preprocess smallstep
for _, ca := range incoming.Smallstep {
if ca.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "certificate_authorities.smallstep: CA name cannot be empty.")
}
ca.Preprocess()
if err := checkAllNames(ca.Name, "smallstep", "Smallstep"); err != nil {
return nil, err
}
}
// preprocess ndes
if incoming.NDESSCEP != nil {
incoming.NDESSCEP.URL = fleet.Preprocess(incoming.NDESSCEP.URL)
incoming.NDESSCEP.AdminURL = fleet.Preprocess(incoming.NDESSCEP.AdminURL)
incoming.NDESSCEP.Username = fleet.Preprocess(incoming.NDESSCEP.Username)
incoming.NDESSCEP.Preprocess()
}
if err := svc.processNDESSCEP(ctx, batchOps, incoming.NDESSCEP, existing.NDESSCEP); err != nil {
@@ -503,6 +556,9 @@ func (svc *Service) getCertificateAuthoritiesBatchOperations(ctx context.Context
if err := svc.processHydrantCAs(ctx, batchOps, incoming.Hydrant, existing.Hydrant); err != nil {
return nil, err
}
if err := svc.processSmallstepCAs(ctx, batchOps, incoming.Smallstep, existing.Smallstep); err != nil {
return nil, err
}
return batchOps, nil
}
@@ -744,6 +800,62 @@ func (svc *Service) processHydrantCAs(ctx context.Context, batchOps *fleet.Certi
return nil
}
func (svc *Service) processSmallstepCAs(ctx context.Context, batchOps *fleet.CertificateAuthoritiesBatchOperations, incomingCAs []fleet.SmallstepSCEPProxyCA, existingCAs []fleet.SmallstepSCEPProxyCA) error {
incomingByName := make(map[string]*fleet.SmallstepSCEPProxyCA)
for _, incoming := range incomingCAs {
// Note: caller is responsible for ensuring incoming list has no duplicates
incomingByName[incoming.Name] = &incoming
}
existingByName := make(map[string]*fleet.SmallstepSCEPProxyCA)
for _, existing := range existingCAs {
// if existing CA isn't in the incoming list, we should delete it
if _, ok := incomingByName[existing.Name]; !ok {
batchOps.Delete = append(batchOps.Delete, &fleet.CertificateAuthority{
Type: string(fleet.CATypeSmallstep),
Name: &existing.Name,
URL: &existing.URL,
ChallengeURL: &existing.ChallengeURL,
Username: &existing.Username,
Password: &existing.Password,
})
}
// Note: datastore is responsible for ensuring no existing list has no duplicates
existingByName[existing.Name] = &existing
}
for name, incoming := range incomingByName {
if err := svc.validateSmallstepSCEPProxy(ctx, incoming, "certificate_authorities.smallstep: "); err != nil {
return err
}
// create the payload to be added or updated
if _, ok := existingByName[name]; ok {
// update existing
batchOps.Update = append(batchOps.Update, &fleet.CertificateAuthority{
Type: string(fleet.CATypeSmallstep),
Name: &incoming.Name,
URL: &incoming.URL,
ChallengeURL: &incoming.ChallengeURL,
Username: &incoming.Username,
Password: &incoming.Password,
})
} else {
// add new
batchOps.Add = append(batchOps.Add, &fleet.CertificateAuthority{
Type: string(fleet.CATypeSmallstep),
Name: &incoming.Name,
URL: &incoming.URL,
ChallengeURL: &incoming.ChallengeURL,
Username: &incoming.Username,
Password: &incoming.Password,
})
}
}
return nil
}
// recordActivitiesBatchApplyCAs records activities for batch operations on certificate authorities
// (i.e. added, edited, deleted).
func (svc *Service) recordActivitiesBatchApplyCAs(ctx context.Context, ops *fleet.CertificateAuthoritiesBatchOperations) error {
@@ -769,6 +881,10 @@ func (svc *Service) recordActivitiesBatchApplyCAs(ctx context.Context, ops *flee
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityAddedHydrant{Name: *ca.Name}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for added hydrant")
}
case string(fleet.CATypeSmallstep):
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityAddedSmallstep{Name: *ca.Name}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for added smallstep SCEP proxy")
}
}
}
for _, ca := range ops.Update {
@@ -789,6 +905,10 @@ func (svc *Service) recordActivitiesBatchApplyCAs(ctx context.Context, ops *flee
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEditedHydrant{Name: *ca.Name}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for edited hydrant")
}
case string(fleet.CATypeSmallstep):
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEditedSmallstep{Name: *ca.Name}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for edited smallstep SCEP proxy")
}
}
}
for _, ca := range ops.Delete {
@@ -809,6 +929,10 @@ func (svc *Service) recordActivitiesBatchApplyCAs(ctx context.Context, ops *flee
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDeletedHydrant{Name: *ca.Name}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for deleted hydrant")
}
case string(fleet.CATypeSmallstep):
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDeletedSmallstep{Name: *ca.Name}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for deleted smallstep SCEP proxy")
}
}
}
@@ -938,6 +1062,32 @@ func (svc *Service) UpdateCertificateAuthority(ctx context.Context, id uint, p f
activity = fleet.ActivityEditedCustomSCEPProxy{Name: caActivityName}
}
if p.SmallstepSCEPProxyCAUpdatePayload != nil {
if p.SmallstepSCEPProxyCAUpdatePayload.IsEmpty() {
return &fleet.BadRequestError{Message: fmt.Sprintf("%sSmallstep SCEP Proxy CA update payload is empty", errPrefix)}
}
if err := p.SmallstepSCEPProxyCAUpdatePayload.ValidateRelatedFields(errPrefix, *oldCA.Name); err != nil {
return err
}
p.SmallstepSCEPProxyCAUpdatePayload.Preprocess()
if err := svc.validateSmallstepSCEPProxyUpdate(ctx, p.SmallstepSCEPProxyCAUpdatePayload, oldCA, errPrefix); err != nil {
return err
}
caToUpdate.Type = string(fleet.CATypeSmallstep)
caToUpdate.Name = p.SmallstepSCEPProxyCAUpdatePayload.Name
caToUpdate.URL = p.SmallstepSCEPProxyCAUpdatePayload.URL
caToUpdate.ChallengeURL = p.SmallstepSCEPProxyCAUpdatePayload.ChallengeURL
caToUpdate.Username = p.SmallstepSCEPProxyCAUpdatePayload.Username
caToUpdate.Password = p.SmallstepSCEPProxyCAUpdatePayload.Password
if caToUpdate.Name != nil {
caActivityName = *caToUpdate.Name
} else {
caActivityName = *oldCA.Name
}
activity = fleet.ActivityEditedSmallstep{Name: caActivityName}
}
if oldCA.Type != caToUpdate.Type {
return &fleet.BadRequestError{Message: fmt.Sprintf("%sThe certificate authority types must be the same.", errPrefix)}
@@ -1146,6 +1296,65 @@ func (svc *Service) validateCustomSCEPProxyUpdate(ctx context.Context, customSCE
return nil
}
func (svc *Service) validateSmallstepSCEPProxyUpdate(ctx context.Context, smallstep *fleet.SmallstepSCEPProxyCAUpdatePayload, oldCa *fleet.CertificateAuthority, errPrefix string) error {
if smallstep.Name != nil {
if err := validateCAName(*smallstep.Name, errPrefix); err != nil {
return err
}
}
if smallstep.URL != nil {
if err := validateURL(*smallstep.URL, "SCEP", errPrefix); err != nil {
return err
}
if err := svc.scepConfigService.ValidateSCEPURL(ctx, *smallstep.URL); err != nil {
level.Error(svc.logger).Log("msg", "Failed to validate Smallstep SCEP URL", "err", err)
return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid SCEP URL. Please correct and try again.", errPrefix)}
}
}
if smallstep.ChallengeURL != nil {
if err := validateURL(*smallstep.ChallengeURL, "Challenge", errPrefix); err != nil {
return err
}
// We want to generate a SmallsteSCEPProxyCA struct with all required fields to verify the admin URL.
// If URL, Username or Password are not being updated we use the existing values from oldCA
smallstepSCEPProxy := fleet.SmallstepSCEPProxyCA{
ChallengeURL: *smallstep.ChallengeURL,
}
if smallstep.URL != nil {
smallstepSCEPProxy.URL = *smallstep.URL
} else {
smallstepSCEPProxy.URL = *oldCa.URL
}
if smallstep.Username != nil {
smallstepSCEPProxy.Username = *smallstep.Username
} else {
smallstepSCEPProxy.Username = *oldCa.Username
}
if smallstep.Password != nil {
smallstepSCEPProxy.Password = *smallstep.Password
} else {
smallstepSCEPProxy.Password = *oldCa.Password
}
if err := svc.scepConfigService.ValidateSmallstepChallengeURL(ctx, smallstepSCEPProxy); err != nil {
level.Error(svc.logger).Log("msg", "Failed to validate Smallstep challenge URL", "err", err)
return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid challenge URL or credentials. Please correct and try again.", errPrefix)}
}
}
if smallstep.Username != nil && *smallstep.Username == "" {
return &fleet.BadRequestError{
Message: fmt.Sprintf("%sSmallstep SCEP Proxy username cannot be empty", errPrefix),
}
}
if smallstep.Password != nil && *smallstep.Password == "" {
return &fleet.BadRequestError{
Message: fmt.Sprintf("%sSmallstep SCEP Proxy password cannot be empty", errPrefix),
}
}
return nil
}
func fmtDuplicateCANameError(name, caType, displayCAType string) error {
return fleet.NewInvalidArgumentError("name", fmt.Sprintf("certificate_authorities.%s.name: Couldnt edit certificate authority. "+
"\"%s\" name is already used by another %s certificate authority. Please choose a different name and try again.", caType, name, displayCAType))
@@ -169,6 +169,13 @@ func TestCreatingCertificateAuthorities(t *testing.T) {
}
if ca.Type != string(fleet.CATypeNDESSCEPProxy) {
assert.Nil(t, ca.AdminURL)
}
if ca.Type != string(fleet.CATypeSmallstep) {
assert.Nil(t, ca.ChallengeURL)
}
// Since username and password is now shared for NDES and Smallstep
if ca.Type != string(fleet.CATypeNDESSCEPProxy) && ca.Type != string(fleet.CATypeSmallstep) {
assert.Nil(t, ca.Username)
assert.Nil(t, ca.Password)
}
@@ -199,8 +206,9 @@ func TestCreatingCertificateAuthorities(t *testing.T) {
digiCertService: digicert.NewService(),
hydrantService: hydrant.NewService(),
scepConfigService: &scep_mock.SCEPConfigService{
ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil },
ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { return nil },
ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil },
ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { return nil },
ValidateSmallstepChallengeURLFunc: func(_ context.Context, _ fleet.SmallstepSCEPProxyCA) error { return nil },
},
}
svc.config.Server.PrivateKey = "supersecret"
@@ -210,6 +218,27 @@ func TestCreatingCertificateAuthorities(t *testing.T) {
return svc, ctx
}
t.Run("Errors when no CA type is specified", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createRequest := fleet.CertificateAuthorityPayload{}
createdCA, err := svc.NewCertificateAuthority(ctx, createRequest)
require.EqualError(t, err, "Couldn't add certificate authority. A certificate authority must be specified")
require.Nil(t, createdCA)
})
t.Run("Errors when multiple CA types are specified", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createRequest := fleet.CertificateAuthorityPayload{
DigiCert: &fleet.DigiCertCA{},
Hydrant: &fleet.HydrantCA{},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createRequest)
require.EqualError(t, err, "Couldn't add certificate authority. Only one certificate authority can be created at a time")
require.Nil(t, createdCA)
})
t.Run("Create DigiCert CA - Happy path", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
@@ -395,6 +424,36 @@ func TestCreatingCertificateAuthorities(t *testing.T) {
verifyNilFieldsForType(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - Happy path", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "SmallstepWIFI",
URL: "https://smallstep.example.com",
ChallengeURL: "https://smallstep.example.com/challenge",
Username: "smallstep_user",
Password: "smallstep_password",
},
}
_, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.EqualError(t, err, "mock error to avoid NewActivity panic")
require.Len(t, createdCAs, 1)
createdCA := createdCAs[0]
assert.Equal(t, createSmallstepRequest.Smallstep.Name, *createdCA.Name)
assert.Equal(t, createSmallstepRequest.Smallstep.URL, *createdCA.URL)
assert.Equal(t, string(fleet.CATypeSmallstep), createdCA.Type)
require.NotNil(t, createdCA.ChallengeURL)
assert.Equal(t, createSmallstepRequest.Smallstep.ChallengeURL, *createdCA.ChallengeURL)
require.NotNil(t, createdCA.Username)
assert.Equal(t, createSmallstepRequest.Smallstep.Username, *createdCA.Username)
require.NotNil(t, createdCA.Password)
assert.Equal(t, createSmallstepRequest.Smallstep.Password, *createdCA.Password)
verifyNilFieldsForType(t, createdCA)
})
t.Run("Create DigiCert CA - Bad Name", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
@@ -886,6 +945,150 @@ func TestCreatingCertificateAuthorities(t *testing.T) {
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - bad name", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "Smallstep SCEP WIFI",
URL: "https://smallstep.example.com",
ChallengeURL: "https://smallstep.example.com/challenge",
Username: "smallstep_user",
Password: "smallstep_password",
},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.ErrorContains(t, err, "Invalid characters in the \"name\" field.")
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - invalid URL format", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "SmallstepSCEPWIFI",
URL: "bozo",
ChallengeURL: "https://smallstep.example.com/challenge",
Username: "smallstep_user",
Password: "smallstep_password",
},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.ErrorContains(t, err, "Invalid Smallstep SCEP URL.")
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - empty username", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "SmallstepSCEPWIFI",
URL: "https://smallstep.example.com",
ChallengeURL: "https://smallstep.example.com/challenge",
Username: "",
Password: "smallstep_password",
},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.ErrorContains(t, err, "Smallstep username cannot be empty")
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - empty password", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "SmallstepSCEPWIFI",
URL: "https://smallstep.example.com",
ChallengeURL: "https://smallstep.example.com/challenge",
Username: "smallstep_user",
Password: "",
},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.ErrorContains(t, err, "Smallstep password cannot be empty")
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - masked password", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "SmallstepSCEPWIFI",
URL: "https://smallstep.example.com",
ChallengeURL: "https://smallstep.example.com/challenge",
Username: "smallstep_user",
Password: fleet.MaskedPassword,
},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.ErrorContains(t, err, "Smallstep password cannot be empty")
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - invalid SCEP URL", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
svc.scepConfigService = &scep_mock.SCEPConfigService{
ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return errors.New("some error") },
}
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "SmallstepSCEPWIFI",
URL: "https://smallstep.example.com",
ChallengeURL: "https://smallstep.example.com/challenge",
Username: "smallstep_user",
Password: "smallstep_password",
},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.ErrorContains(t, err, "Invalid SCEP URL. Please correct and try again.")
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
t.Run("Create Smallstep SCEP CA - invalid challenge validation", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
svc.scepConfigService = &scep_mock.SCEPConfigService{
ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil },
ValidateSmallstepChallengeURLFunc: func(_ context.Context, _ fleet.SmallstepSCEPProxyCA) error {
return errors.New("some error")
},
}
createSmallstepRequest := fleet.CertificateAuthorityPayload{
Smallstep: &fleet.SmallstepSCEPProxyCA{
Name: "SmallstepSCEPWIFI",
URL: "https://smallstep.example.com",
ChallengeURL: "bozo",
Username: "smallstep_user",
Password: "smallstep_password",
},
}
createdCA, err := svc.NewCertificateAuthority(ctx, createSmallstepRequest)
require.ErrorContains(t, err, "Invalid challenge URL or credentials.")
require.Len(t, createdCAs, 0)
require.Nil(t, createdCA)
})
}
func TestUpdatingCertificateAuthorities(t *testing.T) {
@@ -903,6 +1106,7 @@ func TestUpdatingCertificateAuthorities(t *testing.T) {
hydrantID := uint(2)
scepID := uint(3)
ndesID := uint(4)
smallstepID := uint(5)
createdCAs := []*fleet.CertificateAuthority{}
baseSetupForCATests := func() (*Service, context.Context) {
ds := new(mock.Store)
@@ -950,7 +1154,17 @@ func TestUpdatingCertificateAuthorities(t *testing.T) {
Password: ptr.String("ndes-password"),
}
createdCAs = append(createdCAs, digicertCA, hydrantCA, customSCEPCA, ndesCA)
smallstepCA := &fleet.CertificateAuthority{
ID: smallstepID,
Name: ptr.String("Smallstep CA"),
URL: ptr.String("https://smallstep.example.com"),
Type: string(fleet.CATypeSmallstep),
ChallengeURL: ptr.String("https://smallstep.example.com/challenge"),
Username: ptr.String("smallstep-username"),
Password: ptr.String("smallstep-password"),
}
createdCAs = append(createdCAs, digicertCA, hydrantCA, customSCEPCA, ndesCA, smallstepCA)
ds.GetCertificateAuthorityByIDFunc = func(ctx context.Context, id uint, includeSecrets bool) (*fleet.CertificateAuthority, error) {
for _, ca := range createdCAs {
if ca.ID == id {
@@ -987,8 +1201,9 @@ func TestUpdatingCertificateAuthorities(t *testing.T) {
digiCertService: digicert.NewService(),
hydrantService: hydrant.NewService(),
scepConfigService: &scep_mock.SCEPConfigService{
ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil },
ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { return nil },
ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil },
ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { return nil },
ValidateSmallstepChallengeURLFunc: func(_ context.Context, _ fleet.SmallstepSCEPProxyCA) error { return nil },
},
}
svc.config.Server.PrivateKey = "supersecret"
@@ -998,6 +1213,29 @@ func TestUpdatingCertificateAuthorities(t *testing.T) {
return svc, ctx
}
t.Run("Errors on empty payload", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
err := svc.UpdateCertificateAuthority(ctx, digicertID, fleet.CertificateAuthorityUpdatePayload{})
require.EqualError(t, err, "Couldn't edit certificate authority. A certificate authority must be specified")
})
t.Run("Errors on multiple payloads", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
DigiCertCAUpdatePayload: &fleet.DigiCertCAUpdatePayload{
APIToken: ptr.String("updated-api-token"),
},
HydrantCAUpdatePayload: &fleet.HydrantCAUpdatePayload{
ClientSecret: ptr.String("updated-secret"),
},
}
err := svc.UpdateCertificateAuthority(ctx, digicertID, payload)
require.EqualError(t, err, "Couldn't edit certificate authority. Only one certificate authority can be edited at a time")
})
t.Run("Errors if no certificate authority is found", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
@@ -1024,6 +1262,9 @@ func TestUpdatingCertificateAuthorities(t *testing.T) {
ndesID: {
NDESSCEPProxyCAUpdatePayload: &fleet.NDESSCEPProxyCAUpdatePayload{},
},
smallstepID: {
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{},
},
}
for id, payload := range payloadMap {
@@ -1524,10 +1765,157 @@ func TestUpdatingCertificateAuthorities(t *testing.T) {
require.EqualError(t, err, "Couldn't edit certificate authority. Insufficient permissions for NDES SCEP admin URL. Please correct and try again.")
})
})
t.Run("Smallstep SCEP", func(t *testing.T) {
t.Run("Full update succeeds", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
Name: ptr.String("Updated_Smallstep"),
URL: ptr.String("https://smallstep.example.com"),
ChallengeURL: ptr.String("https://smallstep.example.com/challenge"),
Username: ptr.String("smallstep_user"),
Password: ptr.String("smallstep_password"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "mock error to avoid NewActivity panic")
})
t.Run("Bad name", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
Name: ptr.String("Updated Smallstep"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "validation failed: name Couldn't edit certificate authority. Invalid characters in the \"name\" field. Only letters, numbers and underscores allowed.")
})
t.Run("Invalid URL format", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
URL: ptr.String("bozo"),
ChallengeURL: ptr.String("https://smallstep.example.com/challenge"),
Username: ptr.String("updated-username"),
Password: ptr.String("updated-password"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "validation failed: url Couldn't edit certificate authority. Invalid SCEP URL. Please correct and try again.")
})
t.Run("Invalid Challenge URL format", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
URL: ptr.String("https://smallstep.example.com"),
ChallengeURL: ptr.String("bozo"),
Username: ptr.String("updated-username"),
Password: ptr.String("updated-password"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "validation failed: url Couldn't edit certificate authority. Invalid Challenge URL. Please correct and try again.")
})
t.Run("Bad Smallstep SCEP URL", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
svc.scepConfigService = &scep_mock.SCEPConfigService{
ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil },
ValidateSmallstepChallengeURLFunc: func(_ context.Context, _ fleet.SmallstepSCEPProxyCA) error {
return errors.New("some error")
},
}
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
URL: ptr.String("https://smallstep.example.com"),
ChallengeURL: ptr.String("https://smallstep.example.com/challenge"),
Username: ptr.String("updated-username"),
Password: ptr.String("updated-password"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "Couldn't edit certificate authority. Invalid challenge URL or credentials. Please correct and try again.")
})
t.Run("Requires all fields when updating URL", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
URL: ptr.String("https://smallstep.example.com"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "Couldn't edit certificate authority. \"challenge_url\", \"username\" and \"password\" must be set when modifying \"url\" of an existing certificate authority: Smallstep CA.")
})
t.Run("Requires password and username when updating challenge URL", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
ChallengeURL: ptr.String("https://smallstep.example.com/challenge"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "Couldn't edit certificate authority. \"username\" and \"password\" must be set when modifying \"challenge_url\" of an existing certificate authority: Smallstep CA.")
})
t.Run("Requires password when updating username", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
Username: ptr.String("updated-username"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "Couldn't edit certificate authority. \"password\" must be set when modifying \"username\" of an existing certificate authority: Smallstep CA.")
})
t.Run("Errors on empty username", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
Username: ptr.String(""),
Password: ptr.String("updated-password"),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "Couldn't edit certificate authority. Smallstep SCEP Proxy username cannot be empty")
})
t.Run("Errors on empty password", func(t *testing.T) {
svc, ctx := baseSetupForCATests()
payload := fleet.CertificateAuthorityUpdatePayload{
SmallstepSCEPProxyCAUpdatePayload: &fleet.SmallstepSCEPProxyCAUpdatePayload{
Username: ptr.String("updated-username"),
Password: ptr.String(""),
},
}
err := svc.UpdateCertificateAuthority(ctx, smallstepID, payload)
require.EqualError(t, err, "Couldn't edit certificate authority. Smallstep SCEP Proxy password cannot be empty")
})
})
}
// TODO: Revisit this test, as it seems rather useless (at least the success case) due to it's simplicity
// and not being possible atm. to mock/call free service methods.
func TestDeleteCertificateAuthority(t *testing.T) {
t.Parallel()
+83 -4
View File
@@ -1,7 +1,9 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -19,6 +21,7 @@ import (
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log"
"github.com/google/uuid"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
@@ -29,10 +32,11 @@ var (
)
const (
fullPasswordCache = "The password cache is full."
ndesInsufficientPermissions = "You do not have sufficient permission to enroll with SCEP."
MessageSCEPProxyNotConfigured = "SCEP proxy is not configured"
NDESChallengeInvalidAfter = 57 * time.Minute
fullPasswordCache = "The password cache is full."
ndesInsufficientPermissions = "You do not have sufficient permission to enroll with SCEP."
MessageSCEPProxyNotConfigured = "SCEP proxy is not configured"
NDESChallengeInvalidAfter = 57 * time.Minute
SmallstepChallengeInvalidAfter = 57 * time.Minute // TODO(sca): confirm expected expiration time for smallstep
)
type scepProxyService struct {
@@ -164,6 +168,7 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier
hostUUID, profileUUID)}
}
var scepURL string
switch profile.Type {
case fleet.CAConfigNDES:
if groupedCAs.NDESSCEP == nil {
@@ -180,6 +185,31 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier
return "", &scepserver.BadRequestError{Message: "challenge password has expired"}
}
scepURL = groupedCAs.NDESSCEP.URL
case fleet.CAConfigSmallstep:
if len(groupedCAs.Smallstep) < 1 {
return "", &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured}
}
for _, ca := range groupedCAs.Smallstep {
if ca.Name == profile.CAName {
scepURL = ca.URL
break
}
}
// TODO(sca): confirm if this resend method works for smallstep or if we need to use
// something like the approach taken for custom SCEP profiles (where we blank the command uuid
// to force a regeneration of the command bytes)
// Also confirm the expected expiration time for smallstep challenges
if checkChallenge && profile.ChallengeRetrievedAt != nil && profile.ChallengeRetrievedAt.Add(NDESChallengeInvalidAfter).Before(time.Now()) {
// The challenge password was retrieved for this profile, and is now invalid.
// We need to resend the profile with a new challenge password.
// Note: we don't actually know if it is invalid, and we can't get that exact feedback from SCEP server.
if err = svc.ds.ResendHostMDMProfile(ctx, hostUUID, profileUUID); err != nil {
return "", ctxerr.Wrap(ctx, err, "resending host mdm profile")
}
return "", &scepserver.BadRequestError{Message: "challenge password has expired"}
}
case fleet.CAConfigCustomSCEPProxy:
if len(groupedCAs.CustomScepProxy) < 1 {
return "", &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured}
@@ -342,3 +372,52 @@ func (s *SCEPConfigService) ValidateSCEPURL(ctx context.Context, url string) err
}
return nil
}
func (s *SCEPConfigService) ValidateSmallstepChallengeURL(ctx context.Context, ca fleet.SmallstepSCEPProxyCA) error {
_, err := s.GetSmallstepSCEPChallenge(ctx, ca)
return err
}
func (s *SCEPConfigService) GetSmallstepSCEPChallenge(ctx context.Context, ca fleet.SmallstepSCEPProxyCA) (string, error) {
// Get the challenge from Smallstep
client := fleethttp.NewClient(fleethttp.WithTimeout(30 * time.Second))
client.Transport = ntlmssp.Negotiator{
RoundTripper: fleethttp.NewTransport(),
}
var reqBody bytes.Buffer
if err := json.NewEncoder(&reqBody).Encode(fleet.SmallstepChallengeRequestBody{
Webhook: fleet.SmallstepChallengeWebhook{
ID: 1,
WebhookEvent: "SCEPChallenge",
EventTimestamp: time.Now().Unix(),
Name: "SCEPChallenge",
},
Event: fleet.SmallstepChallengeEvent{
SCEPServerURL: ca.URL,
PayloadIdentifier: uuid.New().String(),
PayloadTypes: []string{"com.apple.security.scep"},
},
}); err != nil {
return "", ctxerr.Wrap(ctx, err, "encoding params as JSON")
}
req, err := http.NewRequest(http.MethodPost, ca.ChallengeURL, &reqBody)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "creating request")
}
req.SetBasicAuth(ca.Username, ca.Password)
resp, err := client.Do(req)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "sending request")
}
if resp.StatusCode != http.StatusOK {
return "", ctxerr.Wrap(ctx, fmt.Errorf("status code %d", resp.StatusCode), "getting Smallstep SCEP challenge")
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "reading response body")
}
return string(b), nil
}
+1 -1
View File
@@ -97,7 +97,7 @@ func TestValidateNDESSCEPAdminURL(t *testing.T) {
assert.NoError(t, err)
}
func TestValidateNDESSCEPURL(t *testing.T) {
func TestValidateSCEPURL(t *testing.T) {
t.Parallel()
srv := NewTestSCEPServer(t)
+15
View File
@@ -4,6 +4,7 @@ import (
"crypto/x509"
_ "embed"
"encoding/binary"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -133,6 +134,20 @@ func NewTestNDESAdminServer(t *testing.T, responseTemplate string, responseStatu
return ndesAdminServer
}
func NewTestDynamicChallengeServer(t *testing.T) *httptest.Server {
t.Helper()
dynamicChallengeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Println(r.URL.Path)
_, err := w.Write([]byte("dynamic challenge"))
require.NoError(t, err)
}))
t.Cleanup(dynamicChallengeServer.Close)
return dynamicChallengeServer
}
// utf16FromString returns the UTF-16 encoding of the UTF-8 string s, with a terminating NUL added.
// If s contains a NUL byte at any location, it returns (nil, syscall.EINVAL).
func utf16FromString(s string) ([]uint16, error) {