diff --git a/changes/25822-digicert-integration b/changes/25822-digicert-integration new file mode 100644 index 0000000000..ecb438397c --- /dev/null +++ b/changes/25822-digicert-integration @@ -0,0 +1 @@ +Added integration with DigiCert Trust Lifecycle Manager. Fleet admins can now deploy DigiCert certificates to their macOS devices via configuration profiles. diff --git a/ee/server/service/digicert/digicert.go b/ee/server/service/digicert/digicert.go index a54284af96..42ef65c52d 100644 --- a/ee/server/service/digicert/digicert.go +++ b/ee/server/service/digicert/digicert.go @@ -2,17 +2,24 @@ package digicert import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "net/http" "net/url" "strings" "time" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/go-json-experiment/json" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" + "software.sslmate.com/src/go-pkcs12" ) // REST client for https://one.digicert.com/mpki/docs/swagger-ui/index.html @@ -36,14 +43,7 @@ func WithTimeout(t time.Duration) Opt { func VerifyProfileID(ctx context.Context, logger kitlog.Logger, config fleet.DigiCertIntegration, opts ...Opt) error { - o := integrationOpts{ - timeout: defaultTimeout, - } - for _, opt := range opts { - opt(&o) - } - - client := fleethttp.NewClient(fleethttp.WithTimeout(o.timeout)) + client := fleethttp.NewClient(fleethttp.WithTimeout(populateOpts(opts).timeout)) config.URL = strings.TrimRight(config.URL, "/") req, err := http.NewRequest("GET", config.URL+"/mpki/api/v2/profile/"+url.PathEscape(config.ProfileID), nil) @@ -73,6 +73,152 @@ func VerifyProfileID(ctx context.Context, logger kitlog.Logger, config fleet.Dig if err != nil { return ctxerr.Wrap(ctx, err, "unmarshaling DigiCert response") } + if p.Status != "Active" { + return ctxerr.Errorf(ctx, "DigiCert profile status is not Active: %s", p.Status) + } level.Debug(logger).Log("msg", "DigiCert profile verified", "id", p.ID, "name", p.Name, "status", p.Status) return nil } + +func populateOpts(opts []Opt) integrationOpts { + o := integrationOpts{ + timeout: defaultTimeout, + } + for _, opt := range opts { + opt(&o) + } + return o +} + +func GetCertificate(ctx context.Context, logger kitlog.Logger, config fleet.DigiCertIntegration, opts ...Opt) (pfxData []byte, + password string, err error) { + client := fleethttp.NewClient(fleethttp.WithTimeout(populateOpts(opts).timeout)) + + // Generate a CSR (Certificate Signing Request). + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "generating RSA private key") + } + + csrTemplate := &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: config.CertificateCommonName, + }, + } + + // TODO(#26609): Add support for User Principal Name + + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privateKey) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "creating CSR") + } + + csr := strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csrBytes, + }))) + + reqBody := map[string]interface{}{ + "profile": map[string]string{ + "id": config.ProfileID, + }, + "seat": map[string]string{ + "seat_id": config.CertificateSeatID, + }, + "delivery_format": "x509", + "attributes": map[string]interface{}{ + "subject": map[string]string{ + "common_name": config.CertificateCommonName, + }, + }, + "csr": csr, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "marshaling request body") + } + + config.URL = strings.TrimRight(config.URL, "/") + req, err := http.NewRequest("POST", config.URL+"/mpki/api/v1/certificate", strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "creating DigiCert POST request") + } + + req.Header.Set("X-API-key", config.APIToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "sending DigiCert POST request") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + // Try to see if errors are present in body + type errorResponse struct { + Errors []struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"errors"` + } + var errResp errorResponse + err = json.UnmarshalRead(resp.Body, &errResp) + if err != nil || len(errResp.Errors) == 0 { + return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert status code for POST request: %d", resp.StatusCode) + } + + combinedErrorMessages := make([]string, len(errResp.Errors)) + for i, e := range errResp.Errors { + combinedErrorMessages[i] = e.Message + } + return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert status code for POST request: %d, errors: %s", resp.StatusCode, + strings.Join(combinedErrorMessages, "; ")) + } + + type certificateResponse struct { + SerialNumber string `json:"serial_number"` + DeliveryFormat string `json:"delivery_format"` + Certificate string `json:"certificate"` + } + + var certResp certificateResponse + err = json.UnmarshalRead(resp.Body, &certResp) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "unmarshaling DigiCert POST response") + } + + if certResp.DeliveryFormat != "x509" { + return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert delivery format: %s", certResp.DeliveryFormat) + } + + if len(certResp.Certificate) == 0 { + return nil, "", ctxerr.Errorf(ctx, "did not receive DigiCert certificate") + } + + level.Debug(logger).Log("msg", "DigiCert certificate created", "serial_number", certResp.SerialNumber) + + // Decode the certificate from PEM format + certBlock, _ := pem.Decode([]byte(certResp.Certificate)) + if certBlock == nil { + return nil, "", ctxerr.Errorf(ctx, "failed to decode certificate PEM block") + } + + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "parsing certificate from PEM") + } + + // Encode the private key and certificate into PKCS12 + password, err = server.GenerateRandomText(10) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "generating password for PKCS12 bundle") + } + pkcs12Data, err := pkcs12.Legacy.Encode(privateKey, cert, nil, password) + if err != nil { + return nil, "", ctxerr.Wrap(ctx, err, "creating PKCS12 bundle") + } + + return pkcs12Data, password, nil +} diff --git a/server/datastore/mysql/ca_config_assets.go b/server/datastore/mysql/ca_config_assets.go index a2726113d9..25403a310c 100644 --- a/server/datastore/mysql/ca_config_assets.go +++ b/server/datastore/mysql/ca_config_assets.go @@ -2,6 +2,8 @@ package mysql import ( "context" + "database/sql" + "errors" "fmt" "strings" @@ -73,6 +75,33 @@ func (ds *Datastore) saveCAConfigAssets(ctx context.Context, tx sqlx.ExtContext, return nil } +func (ds *Datastore) GetCAConfigAsset(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) { + stmt := ` + SELECT + name, type, value + FROM + ca_config_assets + WHERE + name = ? AND type = ? + ` + + var asset fleet.CAConfigAsset + if err := sqlx.GetContext(ctx, ds.reader(ctx), &asset, stmt, name, assetType); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, notFound("CAConfigAsset").WithName(name) + } + return nil, ctxerr.Wrapf(ctx, err, "get CA config asset %s", name) + } + + decryptedVal, err := decrypt(asset.Value, ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "decrypting CA config asset %s", asset.Name) + } + asset.Value = decryptedVal + + return &asset, nil +} + func (ds *Datastore) DeleteCAConfigAssets(ctx context.Context, names []string) error { if len(names) == 0 { return nil diff --git a/server/datastore/mysql/ca_config_assets_test.go b/server/datastore/mysql/ca_config_assets_test.go index 8a53ca309f..1dbf5d4f5a 100644 --- a/server/datastore/mysql/ca_config_assets_test.go +++ b/server/datastore/mysql/ca_config_assets_test.go @@ -19,6 +19,7 @@ func TestCAConfigAssets(t *testing.T) { {"GetAllCAConfigAssets", testGetAllCAConfigAssets}, {"SaveCAConfigAssets", testSaveCAConfigAssets}, {"DeleteCAConfigAssets", testDeleteCAConfigAssets}, + {"GetCAConfigAsset", testGetCAConfigAsset}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -154,3 +155,43 @@ func testDeleteCAConfigAssets(t *testing.T, ds *Datastore) { err = ds.DeleteCAConfigAssets(ctx, []string{"non-existent-asset"}) assert.NoError(t, err) } + +func testGetCAConfigAsset(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Test with non-existent asset - should return not found error + asset, err := ds.GetCAConfigAsset(ctx, "non-existent-asset", fleet.CAConfigDigiCert) + assert.Error(t, err) + assert.Nil(t, asset) + assert.True(t, fleet.IsNotFound(err)) + + // Insert some test assets + testAssets := []fleet.CAConfigAsset{ + {Name: "asset1", Type: fleet.CAConfigDigiCert, Value: []byte("value1")}, + {Name: "asset2", Type: fleet.CAConfigCustomSCEPProxy, Value: []byte("value2")}, + } + err = ds.SaveCAConfigAssets(ctx, testAssets) + require.NoError(t, err) + + // Test retrieving an existing asset by name and type + asset, err = ds.GetCAConfigAsset(ctx, "asset1", fleet.CAConfigDigiCert) + require.NoError(t, err) + require.NotNil(t, asset) + assert.Equal(t, "asset1", asset.Name) + assert.Equal(t, fleet.CAConfigDigiCert, asset.Type) + assert.Equal(t, []byte("value1"), asset.Value) + + // Test retrieving another existing asset + asset, err = ds.GetCAConfigAsset(ctx, "asset2", fleet.CAConfigCustomSCEPProxy) + require.NoError(t, err) + require.NotNil(t, asset) + assert.Equal(t, "asset2", asset.Name) + assert.Equal(t, fleet.CAConfigCustomSCEPProxy, asset.Type) + assert.Equal(t, []byte("value2"), asset.Value) + + // Test retrieving an asset with a matching name but different type + asset, err = ds.GetCAConfigAsset(ctx, "asset1", fleet.CAConfigCustomSCEPProxy) + assert.Error(t, err) + assert.Nil(t, asset) + assert.True(t, fleet.IsNotFound(err)) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index b3c8e07374..fb5acf638b 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1448,6 +1448,7 @@ type Datastore interface { // GetAllCAConfigAssets returns the config assets for DigiCert and custom SCEP CAs. GetAllCAConfigAssets(ctx context.Context) (map[string]CAConfigAsset, error) + GetCAConfigAsset(ctx context.Context, name string, assetType CAConfigAssetType) (*CAConfigAsset, error) SaveCAConfigAssets(ctx context.Context, assets []CAConfigAsset) error DeleteCAConfigAssets(ctx context.Context, names []string) error diff --git a/server/mdm/apple/mobileconfig/mobileconfig.go b/server/mdm/apple/mobileconfig/mobileconfig.go index 690b15c796..feb718a5fe 100644 --- a/server/mdm/apple/mobileconfig/mobileconfig.go +++ b/server/mdm/apple/mobileconfig/mobileconfig.go @@ -109,12 +109,17 @@ func getSignedProfileData(mc Mobileconfig) (Mobileconfig, error) { // Adapted from https://github.com/micromdm/micromdm/blob/main/platform/profile/profile.go func (mc Mobileconfig) ParseConfigProfile() (*Parsed, error) { mcBytes := mc + // Remove Fleet variables expected in section. + mcBytes = mdm.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte("")) if mc.isSignedProfile() { profileData, err := getSignedProfileData(mc) if err != nil { return nil, err } mcBytes = profileData + if mdm.ProfileVariableRegex.Match(mcBytes) { + return nil, errors.New("a signed profile cannot contain Fleet variables ($FLEET_VAR_*)") + } } var p Parsed if _, err := plist.Unmarshal(mcBytes, &p); err != nil { @@ -145,12 +150,17 @@ type payloadSummary struct { // See also https://developer.apple.com/documentation/devicemanagement/toplevel func (mc Mobileconfig) payloadSummary() ([]payloadSummary, error) { mcBytes := mc + // Remove Fleet variables expected in section. + mcBytes = mdm.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte("")) if mc.isSignedProfile() { profileData, err := getSignedProfileData(mc) if err != nil { return nil, err } mcBytes = profileData + if mdm.ProfileVariableRegex.Match(mcBytes) { + return nil, errors.New("a signed profile cannot contain Fleet variables ($FLEET_VAR_*)") + } } // unmarshal the values we need from the top-level object diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index cf800dcfa6..aaebd2300b 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -10,10 +10,16 @@ import ( "encoding/base64" "fmt" "io" + "regexp" "github.com/smallstep/pkcs7" ) +var ProfileVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_(?P\w+))|(\${FLEET_VAR_(?P\w+)})`) + +// ProfileDataVariableRegex matches variables present in section of Apple profile, which may cause validation issues. +var ProfileDataVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_DIGICERT_DATA_(?P\w+))|(\${FLEET_VAR_DIGICERT_DATA_(?P\w+)})`) + // MaxProfileRetries is the maximum times an install profile command may be // retried, after which marked as failed and no further attempts will be made // to install the profile. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 6d8ee47022..bd9371d4ce 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -958,6 +958,8 @@ type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConf type GetAllCAConfigAssetsFunc func(ctx context.Context) (map[string]fleet.CAConfigAsset, error) +type GetCAConfigAssetFunc func(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) + type SaveCAConfigAssetsFunc func(ctx context.Context, assets []fleet.CAConfigAsset) error type DeleteCAConfigAssetsFunc func(ctx context.Context, names []string) error @@ -2663,6 +2665,9 @@ type DataStore struct { GetAllCAConfigAssetsFunc GetAllCAConfigAssetsFunc GetAllCAConfigAssetsFuncInvoked bool + GetCAConfigAssetFunc GetCAConfigAssetFunc + GetCAConfigAssetFuncInvoked bool + SaveCAConfigAssetsFunc SaveCAConfigAssetsFunc SaveCAConfigAssetsFuncInvoked bool @@ -6392,6 +6397,13 @@ func (s *DataStore) GetAllCAConfigAssets(ctx context.Context) (map[string]fleet. return s.GetAllCAConfigAssetsFunc(ctx) } +func (s *DataStore) GetCAConfigAsset(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) { + s.mu.Lock() + s.GetCAConfigAssetFuncInvoked = true + s.mu.Unlock() + return s.GetCAConfigAssetFunc(ctx, name, assetType) +} + func (s *DataStore) SaveCAConfigAssets(ctx context.Context, assets []fleet.CAConfigAsset) error { s.mu.Lock() s.SaveCAConfigAssetsFuncInvoked = true diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 634f3b1a10..36ba413cc8 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -15,6 +15,7 @@ import ( "net/url" "os" "regexp" + "strings" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/ee/server/service/digicert" @@ -1044,7 +1045,8 @@ func (svc *Service) processAppConfigCAs(ctx context.Context, newAppConfig *fleet additionalDigiCertValidationNeeded = true for _, ca := range newAppConfig.Integrations.DigiCert.Value { ca.Name = fleet.Preprocess(ca.Name) - if !validateCAName(ca.Name, "digicert", allCANames, invalid) { + if !validateCAName(ca.Name, "digicert", allCANames, invalid) || + !validateCACN(ca.CertificateCommonName, invalid) || !validateSeatID(ca.CertificateSeatID, invalid) { additionalDigiCertValidationNeeded = false continue } @@ -1230,6 +1232,22 @@ func validateCAName(name string, caType string, allCANames map[string]struct{}, return true } +func validateCACN(cn string, invalid *fleet.InvalidArgumentError) bool { + if len(strings.TrimSpace(cn)) == 0 { + invalid.Append("integrations.digicert.certificate_common_name", "CA Common Name (CN) cannot be empty") + return false + } + return true +} + +func validateSeatID(seatID string, invalid *fleet.InvalidArgumentError) bool { + if len(strings.TrimSpace(seatID)) == 0 { + invalid.Append("integrations.digicert.certificate_seat_id", "CA Seat ID cannot be empty") + return false + } + return true +} + type appConfigCAStatus struct { ndes caStatusType digicert map[string]caStatusType diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 0319a17f5c..98b3ce8ad2 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1939,6 +1939,22 @@ func TestAppConfigCAs(t *testing.T) { checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.api_token", "DigiCert API token must be set") }) + t.Run("digicert common name not set", func(t *testing.T) { + mt := setUp() + mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateCommonName = "\n\t" + status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + require.NoError(t, err) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.certificate_common_name", "Common Name (CN) cannot be empty") + }) + + t.Run("digicert seat id not set", func(t *testing.T) { + mt := setUp() + mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateSeatID = "\t\n" + status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + require.NoError(t, err) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.certificate_seat_id", "Seat ID cannot be empty") + }) + t.Run("digicert happy path -- add one", func(t *testing.T) { mt := setUp() status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) @@ -2011,7 +2027,7 @@ func TestAppConfigCAs(t *testing.T) { URL: mockDigiCertServer.URL, APIToken: "api_token", ProfileID: "profile_id", - CertificateCommonName: "", + CertificateCommonName: "other_cn", CertificateUserPrincipalNames: nil, CertificateSeatID: "seat_id", }, @@ -2044,7 +2060,7 @@ func TestAppConfigCAs(t *testing.T) { URL: mockDigiCertServer.URL, APIToken: "api_token", ProfileID: "profile_id", - CertificateCommonName: "", + CertificateCommonName: "other_cn", CertificateUserPrincipalNames: nil, CertificateSeatID: "seat_id", }, diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 9342c56c36..49b87346c0 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -24,6 +24,7 @@ import ( "github.com/docker/go-units" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server" @@ -59,9 +60,12 @@ const ( // FleetVarNDESSCEPChallenge and other variables are used as $FLEET_VAR_. // For example: $FLEET_VAR_NDES_SCEP_CHALLENGE // Currently, we assume the variables are fully unique and not substrings of each other. - FleetVarNDESSCEPChallenge = "NDES_SCEP_CHALLENGE" - FleetVarNDESSCEPProxyURL = "NDES_SCEP_PROXY_URL" - FleetVarHostEndUserEmailIDP = "HOST_END_USER_EMAIL_IDP" + FleetVarNDESSCEPChallenge = "NDES_SCEP_CHALLENGE" + FleetVarNDESSCEPProxyURL = "NDES_SCEP_PROXY_URL" + FleetVarHostEndUserEmailIDP = "HOST_END_USER_EMAIL_IDP" + FleetVarHostHardwareSerial = "HOST_HARDWARE_SERIAL" + FleetVarDigiCertDataPrefix = "DIGICERT_DATA_" + FleetVarDigiCertPasswordPrefix = "DIGICERT_PASSWORD_" // nolint:gosec // G101: Potential hardcoded credentials ) const ( @@ -69,14 +73,18 @@ const ( ) var ( - profileVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_(?P\w+))|(\${FLEET_VAR_(?P\w+)})`) fleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPChallenge, FleetVarNDESSCEPChallenge)) fleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPProxyURL, FleetVarNDESSCEPProxyURL)) fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarHostEndUserEmailIDP, FleetVarHostEndUserEmailIDP)) - fleetVarsSupportedInConfigProfiles = []string{FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarHostEndUserEmailIDP} + fleetVarDigiCertData = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s\w+)|(\${FLEET_VAR_%s\w+})`, FleetVarDigiCertDataPrefix, + FleetVarDigiCertDataPrefix)) + fleetVarDigiCertPassword = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s\w+)|(\${FLEET_VAR_%s\w+})`, FleetVarDigiCertPasswordPrefix, + FleetVarDigiCertPasswordPrefix)) + fleetVarsSupportedInConfigProfiles = []string{FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarHostEndUserEmailIDP, + FleetVarHostHardwareSerial} ) type hostProfileUUID struct { @@ -401,7 +409,11 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r if err := cp.ValidateUserProvided(); err != nil { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()}) } - err = validateConfigProfileFleetVariables(string(cp.Mobileconfig)) + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + err = validateConfigProfileFleetVariables(appConfig, string(cp.Mobileconfig)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating fleet variables") } @@ -465,12 +477,32 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r return newCP, nil } -func validateConfigProfileFleetVariables(contents string) error { +func validateConfigProfileFleetVariables(appConfig *fleet.AppConfig, contents string) error { fleetVars := findFleetVariables(contents) for k := range fleetVars { if !slices.Contains(fleetVarsSupportedInConfigProfiles, k) { - return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles", - k)} + found := false + switch { + case strings.HasPrefix(k, FleetVarDigiCertDataPrefix): + caName := strings.TrimPrefix(k, FleetVarDigiCertDataPrefix) + for _, ca := range appConfig.Integrations.DigiCert.Value { + if ca.Name == caName { + found = true + break + } + } + case strings.HasPrefix(k, FleetVarDigiCertPasswordPrefix): + caName := strings.TrimPrefix(k, FleetVarDigiCertPasswordPrefix) + for _, ca := range appConfig.Integrations.DigiCert.Value { + if ca.Name == caName { + found = true + break + } + } + } + if !found { + return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles", k)} + } } } return nil @@ -3614,7 +3646,7 @@ func ReconcileAppleProfiles( } // Insert variables into profile contents of install targets. Variables may be host-specific. - err = preprocessProfileContents(ctx, appConfig, ds, installTargets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appConfig, ds, logger, installTargets, profileContents, hostProfilesToInstallMap) if err != nil { return err } @@ -3733,6 +3765,7 @@ func preprocessProfileContents( ctx context.Context, appConfig *fleet.AppConfig, ds fleet.Datastore, + logger kitlog.Logger, targets map[string]*cmdTarget, profileContents map[string]mobileconfig.Mobileconfig, hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, @@ -3740,45 +3773,9 @@ func preprocessProfileContents( // This method replaces Fleet variables ($FLEET_VAR_) in the profile contents, generating a unique profile for each host. // For a 2KB profile and 30K hosts, this method may generate ~60MB of profile data in memory. - isNDESSCEPConfigured := func(profUUID string, target *cmdTarget) (bool, error) { - if !license.IsPremium(ctx) { - profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs)) - for _, hostUUID := range target.hostUUIDs { - profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] - if !ok { // Should never happen - continue - } - profile.Status = &fleet.MDMDeliveryFailed - profile.Detail = "NDES SCEP Proxy requires a Fleet Premium license." - profilesToUpdate = append(profilesToUpdate, profile) - } - if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { - return false, err - } - return false, nil - } - if !appConfig.Integrations.NDESSCEPProxy.Valid { - profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs)) - for _, hostUUID := range target.hostUUIDs { - profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] - if !ok { // Should never happen - continue - } - profile.Status = &fleet.MDMDeliveryFailed - profile.Detail = "NDES SCEP Proxy is not configured. " + - "Please configure in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol." - profilesToUpdate = append(profilesToUpdate, profile) - } - if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { - return false, err - } - return false, nil - } - return appConfig.Integrations.NDESSCEPProxy.Valid, nil - } - // Copy of NDES SCEP config which will contain unencrypted password, if needed var ndesConfig *fleet.NDESSCEPProxyIntegration + digiCertCAs := make(map[string]*fleet.DigiCertIntegration) var addedTargets map[string]*cmdTarget for profUUID, target := range targets { @@ -3797,10 +3794,11 @@ func preprocessProfileContents( // Do common validation that applies to all hosts in the target valid := true + var digiCertVars digiCertVarsFound for fleetVar := range fleetVars { - switch fleetVar { - case FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL: - configured, err := isNDESSCEPConfigured(profUUID, target) + switch { + case fleetVar == FleetVarNDESSCEPChallenge || fleetVar == FleetVarNDESSCEPProxyURL: + configured, err := getIsNDESSCEPConfiguredFunc(ctx, appConfig, ds, hostProfilesToInstallMap)(profUUID, target) if err != nil { return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration") } @@ -3808,27 +3806,44 @@ func preprocessProfileContents( valid = false break } - case FleetVarHostEndUserEmailIDP: - // No extra validation needed for this variable - default: - // Error out if we find an unknown variable - profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs)) - for _, hostUUID := range target.hostUUIDs { - profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] - if !ok { // Should never happen - continue - } - profile.Status = &fleet.MDMDeliveryFailed - profile.Detail = fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.", - fleetVar) - profilesToUpdate = append(profilesToUpdate, profile) + case fleetVar == FleetVarHostEndUserEmailIDP || fleetVar == FleetVarHostHardwareSerial: + // No extra validation needed for these variables + case strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix) || strings.HasPrefix(fleetVar, FleetVarDigiCertDataPrefix): + var caName string + if strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix) { + digiCertVars.password = true + caName = strings.TrimPrefix(fleetVar, FleetVarDigiCertPasswordPrefix) + } else { + digiCertVars.data = true + caName = strings.TrimPrefix(fleetVar, FleetVarDigiCertDataPrefix) } - if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profiles for unknown variable") + configured, err := getIsDigiCertConfiguredFunc(ctx, appConfig, ds, hostProfilesToInstallMap, digiCertCAs)(profUUID, target, caName) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking DigiCert configuration") + } + if !configured { + valid = false + break + } + default: + // Otherwise, error out since this variable is unknown + detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.", + fleetVar) + _, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, detail) + if err != nil { + return err } valid = false } } + if !digiCertVars.Ok() { + _, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "For DigiCert integration, "+ + "both $FLEET_VAR_DIGICERT_PASSWORD_ and $FLEET_VAR_DIGICERT_DATA_ must be present in the profile.") + if err != nil { + return err + } + valid = false + } if !valid { // We marked the profile as failed, so we will not do any additional processing on it delete(targets, profUUID) @@ -3860,8 +3875,8 @@ func preprocessProfileContents( failed := false for fleetVar := range fleetVars { - switch fleetVar { - case FleetVarNDESSCEPChallenge: + switch { + case fleetVar == FleetVarNDESSCEPChallenge: if ndesConfig == nil { // Retrieve the NDES admin password. This is done once per run. configAssets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetNDESPassword}, nil) @@ -3918,12 +3933,12 @@ func preprocessProfileContents( managedCertificatePayloads = append(managedCertificatePayloads, payload) hostContents = replaceFleetVariable(fleetVarNDESSCEPChallengeRegexp, hostContents, challenge) - case FleetVarNDESSCEPProxyURL: + case fleetVar == FleetVarNDESSCEPProxyURL: // Insert the SCEP URL into the profile contents proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath, url.PathEscape(fmt.Sprintf("%s,%s", hostUUID, profUUID))) hostContents = replaceFleetVariable(fleetVarNDESSCEPProxyURLRegexp, hostContents, proxyURL) - case FleetVarHostEndUserEmailIDP: + case fleetVar == FleetVarHostEndUserEmailIDP: // Insert the end user email IDP into the profile contents emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts) if err != nil { @@ -3949,6 +3964,37 @@ func preprocessProfileContents( break } hostContents = replaceFleetVariable(fleetVarHostEndUserEmailIDPRegexp, hostContents, emails[0]) + case fleetVar == FleetVarHostHardwareSerial: + // TODO(#26609): swap in host serial + case strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix): + // We will replace the password when we populate the certificate data + case strings.HasPrefix(fleetVar, FleetVarDigiCertDataPrefix): + caName := strings.TrimPrefix(fleetVar, FleetVarDigiCertDataPrefix) + ca, ok := digiCertCAs[caName] + if !ok { + continue // Should never happen since we validated/populated DigiCert CAs earlier + } + + // TODO(#26609): populate Fleet vars in the CA fields + + data, password, err := digicert.GetCertificate(ctx, logger, *ca) + if err != nil { + detail := fmt.Sprintf("Couldn't get certificate from DigiCert. %s", err) + err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.cmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert") + } + failed = true + break + } + hostContents = replaceFleetVariable(fleetVarDigiCertData, hostContents, base64.StdEncoding.EncodeToString(data)) + hostContents = replaceFleetVariable(fleetVarDigiCertPassword, hostContents, password) default: // This was handled in the above switch statement, so we should never reach this case } @@ -3983,6 +4029,96 @@ func preprocessProfileContents( return nil } +type digiCertVarsFound struct { + data bool + password bool +} + +// Ok makes sure that both DATA and PASSWORD variables are present in a DigiCert profile. +func (d digiCertVarsFound) Ok() bool { + return d.data && d.password || !d.data && !d.password +} + +func getIsDigiCertConfiguredFunc(ctx context.Context, appConfig *fleet.AppConfig, ds fleet.Datastore, + hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + digiCertCAs map[string]*fleet.DigiCertIntegration) func(profUUID string, target *cmdTarget, caName string) (bool, error) { + return func(profUUID string, target *cmdTarget, caName string) (bool, error) { + if !license.IsPremium(ctx) { + return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "DigiCert integration requires a Fleet Premium license.") + } + if _, ok := digiCertCAs[caName]; ok { + return true, nil + } + configured := false + var digiCertCA *fleet.DigiCertIntegration + if appConfig.Integrations.DigiCert.Valid { + for _, ca := range appConfig.Integrations.DigiCert.Value { + if ca.Name == caName { + digiCertCA = &ca + configured = true + break + } + } + } + if !configured || digiCertCA == nil { + return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, + fmt.Sprintf("DigiCert CA '%s' is not configured. Please configure in Settings > Integrations > Certificates.", caName)) + } + + // Get the API token + asset, err := ds.GetCAConfigAsset(ctx, digiCertCA.Name, fleet.CAConfigDigiCert) + switch { + case fleet.IsNotFound(err): + return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, + fmt.Sprintf("DigiCert CA '%s' is missing API token. Please configure in Settings > Integrations > Certificates.", caName)) + case err != nil: + return false, ctxerr.Wrap(ctx, err, "getting CA config asset") + } + digiCertCA.APIToken = string(asset.Value) + digiCertCAs[caName] = digiCertCA + + return true, nil + } +} + +func getIsNDESSCEPConfiguredFunc(ctx context.Context, appConfig *fleet.AppConfig, ds fleet.Datastore, + hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload) func(profUUID string, target *cmdTarget) (bool, error) { + return func(profUUID string, target *cmdTarget) (bool, error) { + if !license.IsPremium(ctx) { + return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.") + } + if !appConfig.Integrations.NDESSCEPProxy.Valid { + return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, + "NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.") + } + return appConfig.Integrations.NDESSCEPProxy.Valid, nil + } +} + +func markProfilesFailed( + ctx context.Context, + ds fleet.Datastore, + target *cmdTarget, + hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + profUUID string, + detail string, +) (bool, error) { + profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs)) + for _, hostUUID := range target.hostUUIDs { + profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + if !ok { // Should never happen + continue + } + profile.Status = &fleet.MDMDeliveryFailed + profile.Detail = detail + profilesToUpdate = append(profilesToUpdate, profile) + } + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return false, ctxerr.Wrap(ctx, err, "marking host profiles failed") + } + return false, nil +} + func replaceFleetVariable(regExp *regexp.Regexp, contents string, replacement string) string { // Escape XML characters b := make([]byte, 0, len(replacement)) @@ -3994,12 +4130,12 @@ func replaceFleetVariable(regExp *regexp.Regexp, contents string, replacement st func findFleetVariables(contents string) map[string]interface{} { var result map[string]interface{} - matches := profileVariableRegex.FindAllStringSubmatch(contents, -1) + matches := mdm_types.ProfileVariableRegex.FindAllStringSubmatch(contents, -1) if len(matches) == 0 { return nil } nameToIndex := make(map[string]int, 2) - for i, name := range profileVariableRegex.SubexpNames() { + for i, name := range mdm_types.ProfileVariableRegex.SubexpNames() { if name == "" { continue } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 65d222a252..c8a1403143 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -2798,6 +2798,7 @@ func TestPreprocessProfileContents(t *testing.T) { }) ctx := context.Background() + logger := kitlog.NewNopLogger() appCfg := &fleet.AppConfig{} appCfg.ServerSettings.ServerURL = "https://test.example.com" appCfg.MDM.EnabledAndConfigured = true @@ -2805,7 +2806,7 @@ func TestPreprocessProfileContents(t *testing.T) { ds := new(mock.Store) // No-op - err := preprocessProfileContents(ctx, appCfg, ds, nil, nil, nil) + err := preprocessProfileContents(ctx, appCfg, ds, logger, nil, nil, nil) require.NoError(t, err) hostUUID := "host-1" @@ -2845,7 +2846,7 @@ func TestPreprocessProfileContents(t *testing.T) { } // Can't use NDES SCEP proxy with free tier ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree}) - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedPayload) assert.Contains(t, updatedPayload.Detail, "Premium license") @@ -2856,7 +2857,7 @@ func TestPreprocessProfileContents(t *testing.T) { appCfg.Integrations.NDESSCEPProxy.Valid = false updatedPayload = nil populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedPayload) assert.Contains(t, updatedPayload.Detail, "not configured") @@ -2869,7 +2870,7 @@ func TestPreprocessProfileContents(t *testing.T) { appCfg.Integrations.NDESSCEPProxy.Valid = true updatedPayload = nil populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedPayload) assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO") @@ -2914,7 +2915,7 @@ func TestPreprocessProfileContents(t *testing.T) { assert.Empty(t, payload) // no profiles to update since FLEET VAR could not be populated return nil } - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedProfile) assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge) @@ -2928,7 +2929,7 @@ func TestPreprocessProfileContents(t *testing.T) { } updatedProfile = nil populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedProfile) assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge) @@ -2942,7 +2943,7 @@ func TestPreprocessProfileContents(t *testing.T) { } updatedProfile = nil populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedProfile) assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge) @@ -2956,7 +2957,7 @@ func TestPreprocessProfileContents(t *testing.T) { } updatedProfile = nil populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedProfile) assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge) @@ -2983,7 +2984,7 @@ func TestPreprocessProfileContents(t *testing.T) { assert.NotNil(t, payload[0].ChallengeRetrievedAt) return nil } - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) assert.Nil(t, updatedProfile) require.NotEmpty(t, targets) @@ -3006,7 +3007,7 @@ func TestPreprocessProfileContents(t *testing.T) { assert.Empty(t, payload) return nil } - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) assert.Nil(t, updatedProfile) require.NotEmpty(t, targets) @@ -3027,7 +3028,7 @@ func TestPreprocessProfileContents(t *testing.T) { } updatedProfile = nil populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotNil(t, updatedProfile) assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarHostEndUserEmailIDP) @@ -3041,7 +3042,7 @@ func TestPreprocessProfileContents(t *testing.T) { } updatedProfile = nil populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) assert.Nil(t, updatedProfile) require.NotEmpty(t, targets) @@ -3109,7 +3110,7 @@ func TestPreprocessProfileContents(t *testing.T) { } return nil } - err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap) require.NoError(t, err) require.NotEmpty(t, targets) assert.Len(t, targets, 3) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 97fa988ee6..1a0259a615 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "database/sql" @@ -78,6 +79,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "software.sslmate.com/src/go-pkcs12" ) func TestIntegrationsMDM(t *testing.T) { @@ -13240,34 +13242,27 @@ func (s *integrationMDMTestSuite) TestSCEPProxy() { assert.Equal(t, scep.CertRep, pkiMessage.MessageType) } -func (s *integrationMDMTestSuite) TestDigiCertIntegration() { +func (s *integrationMDMTestSuite) TestDigiCertConfig() { t := s.T() ctx := context.Background() + mockDigiCertServer := createMockDigiCertServer(t) - pathRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`) - mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - matches := pathRegex.FindStringSubmatch(r.URL.Path) - if len(matches) != 2 { - w.WriteHeader(http.StatusBadRequest) - return - } - profileID := matches[1] - - resp := map[string]string{ - "id": profileID, - "name": "Test CA", - "status": "Active", - } - w.Header().Set("Content-Type", "application/json") - err := json.NewEncoder(w).Encode(resp) - require.NoError(t, err) - })) - defer mockDigiCertServer.Close() + // Add DigiCert integration with bad URL + caBad := getDigiCertIntegration("https://httpstat.us/410", "ca") + appConfig := map[string]interface{}{ + "integrations": map[string]interface{}{ + "digicert": []fleet.DigiCertIntegration{caBad}, + }, + } + raw, err := json.Marshal(appConfig) + require.NoError(t, err) + var req modifyAppConfigRequest + req.RawMessage = raw + rawRes := s.Do("PATCH", "/api/latest/fleet/config", &req, http.StatusUnprocessableEntity, "dry_run", "true") + errMsg := extractServerErrorText(rawRes.Body) + require.Contains(t, errMsg, "Could not verify DigiCert profile ID") + _, err = s.ds.GetAllCAConfigAssets(ctx) + assert.True(t, fleet.IsNotFound(err)) // Add 3 DigiCert integrations ca0 := getDigiCertIntegration(mockDigiCertServer.URL, "ca0") @@ -13276,16 +13271,16 @@ func (s *integrationMDMTestSuite) TestDigiCertIntegration() { ca1.APIToken = "api_token1" ca2 := getDigiCertIntegration(mockDigiCertServer.URL, "ca2") ca2.APIToken = "api_token2" - appConfig := map[string]interface{}{ + appConfig = map[string]interface{}{ "integrations": map[string]interface{}{ "digicert": []fleet.DigiCertIntegration{ca0, ca1, ca2}, }, } - raw, err := json.Marshal(appConfig) + raw, err = json.Marshal(appConfig) require.NoError(t, err) - var req modifyAppConfigRequest + req = modifyAppConfigRequest{} req.RawMessage = raw - var res appConfigResponse + res := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res, "dry_run", "true") assert.Empty(t, res.Integrations.DigiCert.Value) _, err = s.ds.GetAllCAConfigAssets(ctx) @@ -13429,6 +13424,288 @@ func (s *integrationMDMTestSuite) TestDigiCertIntegration() { assert.EqualValues(t, caNames, []string{"ca1", "ca2", "ca3"}) } +func (s *integrationMDMTestSuite) TestDigiCertIntegration() { + t := s.T() + ctx := context.Background() + mockDigiCertServer := createMockDigiCertServer(t) + + // Add DigiCert config + ca := getDigiCertIntegration(mockDigiCertServer.URL, "my_CA") + ca.APIToken = "api_token0" + appConfig := map[string]interface{}{ + "integrations": map[string]interface{}{ + "digicert": []fleet.DigiCertIntegration{ca}, + }, + } + raw, err := json.Marshal(appConfig) + require.NoError(t, err) + var req modifyAppConfigRequest + req.RawMessage = raw + var res appConfigResponse + s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res) + assert.Len(t, res.Integrations.DigiCert.Value, 1) + + // Create a host and then enroll to MDM. + host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + setupPusher(s, t, mdmDevice) + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + profiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) + require.NoError(t, err) + require.GreaterOrEqual(t, len(profiles), 0) + // Receive enrollment profiles (we are not checking/testing these here) + for { + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + if cmd == nil { + break + } + _, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + // Add a profile with a bad CA + profile := digiCertForTest("N1", "BadCA", "badName") + rawRes := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "N1", Contents: profile}, + }}, http.StatusBadRequest) + errMsg := extractServerErrorText(rawRes.Body) + require.Contains(t, errMsg, "_badName is not supported") + + // Add good profile + profile = digiCertForTest("N2", "I2", "my_CA") + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "N2", Contents: profile}, + }}, http.StatusNoContent) + profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) + require.NoError(t, err) + require.GreaterOrEqual(t, len(profiles), 1) + + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + p := s.assertConfigProfilesByIdentifier(nil, "I2", true) + require.Contains(t, string(p.Mobileconfig), "com.fleetdm.pkcs12") + + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + require.NotNil(t, cmd, "Expecting PKCS12 certificate") + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + assert.Nil(t, cmd) + require.NotNil(t, fullCmd.Command) + require.NotNil(t, fullCmd.Command.InstallProfile) + rawProfile := fullCmd.Command.InstallProfile.Payload + if !bytes.HasPrefix(rawProfile, []byte(" + + + + PayloadContent + + + Password + $FLEET_VAR_DIGICERT_PASSWORD_%s + PayloadContent + ${FLEET_VAR_DIGICERT_DATA_%s} + PayloadDisplayName + CertificatePKCS12 + PayloadIdentifier + com.fleetdm.pkcs12 + PayloadType + com.apple.security.pkcs12 + PayloadUUID + ee86cfcb-2409-42c2-9394-1f8113412e04 + PayloadVersion + 1 + + + PayloadDisplayName + %s + PayloadIdentifier + %s + PayloadType + Configuration + PayloadUUID + %s + PayloadVersion + 1 + + +`, caName, caName, name, identifier, uuid.New().String())) +} + +func createMockDigiCertServer(t *testing.T) *httptest.Server { + profileRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`) + certRegex := regexp.MustCompile(`^/mpki/api/v1/certificate`) + mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + matches := profileRegex.FindStringSubmatch(r.URL.Path) + if len(matches) != 2 { + w.WriteHeader(http.StatusBadRequest) + return + } + profileID := matches[1] + + resp := map[string]string{ + "id": profileID, + "name": "Test CA", + "status": "Active", + } + err := json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + case http.MethodPost: + if len(certRegex.FindStringSubmatch(r.URL.Path)) != 1 { + w.WriteHeader(http.StatusBadRequest) + return + } + var req struct { + CSR string `json:"csr"` + } + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + + // Decode the PEM format CSR into DER + block, _ := pem.Decode([]byte(req.CSR)) + require.NotNil(t, block, "failed to decode PEM block containing CSR") + require.Equal(t, "CERTIFICATE REQUEST", block.Type, "unexpected PEM block type") + + // Parse the CSR + csr, err := x509.ParseCertificateRequest(block.Bytes) + require.NoError(t, err) + require.NoError(t, csr.CheckSignature()) + + // Setting CertificateCommonName to "Fail" allows us to test a failure getting a DigiCert certificate + if csr.Subject.CommonName == "Fail" { + w.WriteHeader(http.StatusBadRequest) + type errorResponse struct { + Errors interface{} `json:"errors"` + } + err = json.NewEncoder(w).Encode(errorResponse{ + Errors: []struct { + Code string `json:"code"` + Message string `json:"message"` + }{ + { + Message: "Expected Fail", + }, + }, + }) + require.NoError(t, err) + return + } + + // Generate a self-signed certificate based on the CSR + serialNumber := big.NewInt(time.Now().Unix()) + certTemplate := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: csr.Subject, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year validity + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + } + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + + // Encode the certificate to PEM + certPEM := new(bytes.Buffer) + err = pem.Encode(certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + require.NoError(t, err) + + w.WriteHeader(http.StatusCreated) + resp := map[string]string{ + "serial_number": serialNumber.String(), + "delivery_format": "x509", + "certificate": certPEM.String(), + } + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(mockDigiCertServer.Close) + return mockDigiCertServer +} + type noopCertDepot struct{ depot.Depot } func (d *noopCertDepot) Put(_ string, _ *x509.Certificate) error { diff --git a/server/service/mdm.go b/server/service/mdm.go index cbed25c693..4b87a5936e 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1673,7 +1673,7 @@ func (svc *Service) BatchSetMDMProfiles( return nil } - err = validateFleetVariables(ctx, appleProfiles, windowsProfiles, appleDecls) + err = validateFleetVariables(ctx, appCfg, appleProfiles, windowsProfiles, appleDecls) if err != nil { return err } @@ -1756,13 +1756,13 @@ func (svc *Service) BatchSetMDMProfiles( return nil } -func validateFleetVariables(ctx context.Context, appleProfiles map[int]*fleet.MDMAppleConfigProfile, +func validateFleetVariables(ctx context.Context, appConfig *fleet.AppConfig, appleProfiles map[int]*fleet.MDMAppleConfigProfile, windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration, ) error { var err error for _, p := range appleProfiles { - err = validateConfigProfileFleetVariables(string(p.Mobileconfig)) + err = validateConfigProfileFleetVariables(appConfig, string(p.Mobileconfig)) if err != nil { return ctxerr.Wrap(ctx, err, "validating config profile Fleet variables") }