Add one-time challenge support to custom SCEP proxy (#29832)

This commit is contained in:
Sarah Gillespie
2025-06-12 08:56:13 -05:00
committed by GitHub
parent 7877133935
commit 9fcd2e15c2
16 changed files with 649 additions and 71 deletions
+1
View File
@@ -0,0 +1 @@
- Updated custom SCEP proxy implementation to include one-time challenges.
+7
View File
@@ -890,6 +890,13 @@ func newCleanupsAndAggregationSchedule(
return ds.CleanupExpiredPasswordResetRequests(ctx)
},
),
schedule.WithJob(
"expired_challenges",
func(ctx context.Context) error {
_, err := ds.CleanupExpiredChallenges(ctx)
return err
},
),
// Run aggregation jobs after cleanups.
schedule.WithJob(
"query_aggregated_stats",
@@ -102,7 +102,7 @@ sequenceDiagram
<key>PayloadContent</key>
<dict>
<key>Challenge</key>
<string>$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_Test_SCEP</string>
<string>$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CA_NAME</string>
<key>Key Type</key>
<string>RSA</string>
<key>Key Usage</key>
@@ -114,7 +114,7 @@ sequenceDiagram
<array>
<array>
<string>CN</string>
<string>%SerialNumber% WIFI</string>
<string>%SerialNumber% WIFI $FLEET_VAR_SCEP_RENEWAL_ID</string>
</array>
</array>
<array>
@@ -125,12 +125,12 @@ sequenceDiagram
</array>
</array>
<key>URL</key>
<string>${FLEET_VAR_CUSTOM_SCEP_PROXY_URL_Test_SCEP}</string>
<string>$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CA_NAME</string>
</dict>
<key>PayloadDisplayName</key>
<string>SCEP #1</string>
<string>WIFI SCEP</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.custom.scep</string>
<string>com.apple.security.scep.9DCC35A5-72F9-42B7-9A98-7AD9A9CCA3AC</string>
<key>PayloadType</key>
<string>com.apple.security.scep</string>
<key>PayloadUUID</key>
@@ -142,7 +142,7 @@ sequenceDiagram
<key>PayloadDisplayName</key>
<string>SCEP proxy cert</string>
<key>PayloadIdentifier</key>
<string>Fleet.custom.SCEP</string>
<string>Fleet.WiFi</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
+60 -4
View File
@@ -23,8 +23,10 @@ import (
"golang.org/x/text/transform"
)
var _ scepserver.ServiceWithIdentifier = (*scepProxyService)(nil)
var challengeRegex = regexp.MustCompile(`(?i)The enrollment challenge password is: <B> (?P<password>\S*)`)
var (
_ scepserver.ServiceWithIdentifier = (*scepProxyService)(nil)
challengeRegex = regexp.MustCompile(`(?i)The enrollment challenge password is: <B> (?P<password>\S*)`)
)
const (
fullPasswordCache = "The password cache is full."
@@ -90,9 +92,11 @@ func (svc *scepProxyService) GetCACert(ctx context.Context, message string, iden
return res, num, nil
}
// NOTE: Any changes to this method must ensure that the challenge portion of the identifer is
// properly validated using the before proceeding with the PKIOperation.
func (svc *scepProxyService) PKIOperation(ctx context.Context, data []byte, identifier string) ([]byte, error) {
// We only check for expired NDES challenge during this (the last) SCEP request to account for previous requests having large network delays
scepURL, err := svc.validateIdentifier(ctx, identifier, true)
scepURL, err := svc.validateIdentifier(ctx, identifier, true) // checkChallenge must be true to validate the challenge portion of the identifier
if err != nil {
return nil, err
}
@@ -110,7 +114,8 @@ func (svc *scepProxyService) PKIOperation(ctx context.Context, data []byte, iden
}
func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier string, checkChallenge bool) (string,
error) {
error,
) {
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting app config")
@@ -132,6 +137,10 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier
if len(parsedIDs) > 2 {
caName = parsedIDs[2]
}
var fleetChallenge string
if len(parsedIDs) > 3 {
fleetChallenge = parsedIDs[3]
}
if !strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix) {
return "", &scepserver.BadRequestError{Message: fmt.Sprintf("invalid profile UUID (only Apple config profiles are supported): %s",
profileUUID)}
@@ -175,6 +184,22 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier
if !appConfig.Integrations.CustomSCEPProxy.Valid {
return "", &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured}
}
if checkChallenge {
if err := svc.handleFleetChallenge(ctx, fleetChallenge, hostUUID, profileUUID); err != nil {
// FIXME: The layered logging implementation of the scepProxyService not
// intuitive. Can we make it so that we return fleet.ErrWithInternal to
// better capture/log the context errors here?
svc.debugLogger.Log(
"msg", "custom scep proxy: failed to handle fleet challenge",
"host_uuid", hostUUID,
"profile_uuid", profileUUID,
"err", err.Error(),
)
return "", &scepserver.BadRequestError{
Message: "custom scep challenge failed",
}
}
}
for _, ca := range appConfig.Integrations.CustomSCEPProxy.Value {
if ca.Name == profile.CAName {
scepURL = ca.URL
@@ -193,6 +218,37 @@ func (svc *scepProxyService) GetNextCACert(_ context.Context) ([]byte, error) {
return nil, errors.New("GetNextCACert is not implemented for SCEP proxy")
}
// handleFleetChallenge handles the validation of the fleet challenge for custom SCEP profiles as
// well as resending the profile if the challenge cannot be validated. If it is valid, it returns
// nil. If it cannot be validated or if any errors occur while validating or resending the profile,
// it returns a concatenated error.
//
// TODO: Consider refactoring to differentiate between invalid challenge and other errors. As it
// stands, we're resending the profile in both cases.
func (svc *scepProxyService) handleFleetChallenge(ctx context.Context, fleetChallenge string, hostUUID string, profileUUID string) error {
var errs []error
if err := svc.ds.ConsumeChallenge(ctx, fleetChallenge); err != nil {
errs = append(errs, ctxerr.Wrap(ctx, err, "custom scep proxy: validating challenge"))
// FIXME: We really should have a more generic function to handle this, but our existing methods
// for "resending" profiles don't reevaluate the profile variables so they aren't useful for
// custom SCEP profiles where we need to regenerate the SCEP challenge. The main difference between
// the existing flow and the implementation below is that we need to blank the command uuid in order
// get the reconcile cron to reevaluate the command template to generate the challenge. Otherwise,
// it just sends the old bytes again. It feels like we some leaky abstrations somewhere that we need
// to clean up.
if err := svc.ds.ResendHostCustomSCEPProfile(ctx, hostUUID, profileUUID); err != nil {
errs = append(errs, ctxerr.Wrap(ctx, err, "custom scep proxy: resending host mdm profile"))
}
}
if len(errs) > 0 {
return ctxerr.Wrap(ctx, errors.Join(errs...), "custom scep proxy: failed to handle fleet challenge")
}
return nil
}
type SCEPConfigService struct {
logger log.Logger
// Timeout is the timeout for SCEP requests.
-1
View File
@@ -119,7 +119,6 @@ func TestValidateNDESSCEPURL(t *testing.T) {
proxy.URL = srv.URL + "/bozo"
err = svc.ValidateSCEPURL(context.Background(), proxy.URL)
assert.ErrorContains(t, err, "could not retrieve CA certificate")
}
// utf16FromString returns the UTF-16 encoding of the UTF-8 string s, with a terminating NUL added.
+60
View File
@@ -744,6 +744,66 @@ func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
return err
}
// ResendHostCustomSCEPProfile marks a custom SCEP profile to be resent to the host with the given UUID. It
// also deactivates prior nano commands for the profile UUID and host UUID.
//
// FIXME: We really should have a more generic function to handle this, but our existing methods
// for "resending" profiles don't reevaluate the profile variables so they aren't useful for
// custom SCEP profiles where we need to regenerate the SCEP challenge. The main difference between
// the existing flow and the implementation below is that we need to blank the command uuid in order
// get the reconcile cron to reevaluate the command template to generate the challenge. Otherwise,
// it just sends the old bytes again. It feels like we some leaky abstrations somewhere that we need
// to clean up.
func (ds *Datastore) ResendHostCustomSCEPProfile(ctx context.Context, hostUUID string, profUUID string) error {
deactivateNanoStmt := `
UPDATE
nano_enrollment_queue
JOIN host_mdm_apple_profiles hmap
ON hmap.command_uuid = nano_enrollment_queue.command_uuid AND
hmap.host_uuid = nano_enrollment_queue.id
SET
nano_enrollment_queue.active = 0
WHERE
hmap.profile_uuid = ? AND
hmap.host_uuid = ?`
updateStmt := `
UPDATE
host_mdm_apple_profiles
SET
status = NULL,
command_uuid = '',
detail = '',
retries = 0,
variables_updated_at = NOW(6)
WHERE
profile_uuid = ? AND
host_uuid = ? AND
operation_type = ?`
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, deactivateNanoStmt, profUUID, hostUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "deactivating nano_enrollment_queue for commands that were pending send to host")
}
if rows, _ := res.RowsAffected(); rows == 0 {
// this should never happen, log for debugging
level.Error(ds.logger).Log("msg", "resend custom scep profile: nano not deactivated", "host_uuid", hostUUID, "profile_uuid", profUUID)
}
res, err = tx.ExecContext(ctx, updateStmt, profUUID, hostUUID, fleet.MDMOperationTypeInstall)
if err != nil {
return ctxerr.Wrap(ctx, err, "resending host MDM profile")
}
if rows, _ := res.RowsAffected(); rows == 0 {
// this should never happen, log for debugging
level.Error(ds.logger).Log("msg", "resend custom scep profile: host mdm apple profiles not updated", "host_uuid", hostUUID, "profile_uuid", profUUID)
}
return nil
})
}
func (ds *Datastore) NewMDMAppleEnrollmentProfile(
ctx context.Context,
payload fleet.MDMAppleEnrollmentProfilePayload,
+100
View File
@@ -0,0 +1,100 @@
package mysql
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/jmoiron/sqlx"
)
// NewChallenge generates a random, base64-encoded challenge and inserts it into the challenges
// table. It returns the generated challenge or an error if the insertion fails.
func (ds *Datastore) NewChallenge(ctx context.Context) (string, error) {
key := make([]byte, 24)
_, err := rand.Read(key)
if err != nil {
return "", err
}
challenge := base64.URLEncoding.EncodeToString(key)
_, err = ds.writer(ctx).ExecContext(ctx, `INSERT INTO challenges (challenge) VALUES (?)`, challenge)
if err != nil {
return "", err
}
fmt.Println("New challenge created:", challenge)
return challenge, nil
}
// ConsumeChallenge checks if a valid challenge exists in the challenges table
// and deletes it if it does. The error will include sql.ErrNoRows if the challenge
// is not found or is expired.
func (ds *Datastore) ConsumeChallenge(ctx context.Context, challenge string) error {
if challenge == "" {
// no challenge provided, treat as invalid
return ctxerr.Wrap(ctx, sql.ErrNoRows, "consume challenge called with empty challenge")
}
// use transaction to ensure atomicity of the challenge check and deletion
var valid bool
// msg will hold the reason for invalidation if applicable because any transaction err means
// we want to retry/rollback, rather when we want to return a validation error to the caller
var msg string
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// check if matching challenge exists and retrieve its creation time
var createdAt time.Time
if err := sqlx.GetContext(ctx, tx, &createdAt, `SELECT created_at FROM challenges WHERE challenge = ?`, challenge); err != nil {
if err == sql.ErrNoRows {
// invalid, challenge not found
msg = "challenge not found"
return nil
}
// some other error, return it
return ctxerr.Wrap(ctx, err, "get challenge")
}
// delete challenge regardless of validity
r, err := tx.ExecContext(ctx, `DELETE FROM challenges WHERE challenge = ?`, challenge)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete challenge")
}
if rowCt, _ := r.RowsAffected(); rowCt < 1 {
// unlikely to happen since just checked existence and we're in a transaction,
// but we'll treat as invalid so we log as error for debugging purposes just in case
msg = "challenge not found for deletion"
return nil
}
// check expiry
if time.Since(createdAt) <= fleet.OneTimeChallengeTTL {
valid = true
} else {
msg = "challenge expired"
}
return nil
})
switch {
case err != nil:
// if we encountered an error during the transaction, return it
return ctxerr.Wrap(ctx, err, "consume challenge transaction")
case valid:
// challenge consumed successfully
return nil
default:
// challenge was invalid or expired, treat as not found
return ctxerr.Wrap(ctx, sql.ErrNoRows, msg)
}
}
// CleanupExpiredChallenges removes expired challenges from the challenges table.
func (ds *Datastore) CleanupExpiredChallenges(ctx context.Context) (int64, error) {
res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM challenges WHERE created_at < ?`, time.Now().Add(-fleet.OneTimeChallengeTTL))
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "cleanup expired challenges")
}
rowCt, _ := res.RowsAffected()
return rowCt, nil
}
@@ -0,0 +1,33 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20250609112613, Down_20250609112613)
}
func Up_20250609112613(tx *sql.Tx) error {
stmt := `
-- The challenges table holds generated challenges intended for single-use applications.
-- Whenever a challenge is checked it should be deleted from this table.
CREATE TABLE IF NOT EXISTS challenges (
-- challenge is randomly generated string encoded with base64.URLEncoding.
challenge CHAR(32),
created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (challenge)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;`
if _, err := tx.Exec(stmt); err != nil {
return fmt.Errorf("creating challenges table: %w", err)
}
return nil
}
func Down_20250609112613(tx *sql.Tx) error {
return nil
}
File diff suppressed because one or more lines are too long
+17
View File
@@ -2056,6 +2056,10 @@ type Datastore interface {
// ListHostMDMManagedCertificates returns the managed certificates for the given host UUID
ListHostMDMManagedCertificates(ctx context.Context, hostUUID string) ([]*MDMManagedCertificate, error)
// ResendHostCustomSCEPProfile marks a custom SCEP profile to be resent to the host with the given UUID. It
// also deactivates prior nano commands for the profile UUID and host UUID.
ResendHostCustomSCEPProfile(ctx context.Context, hostUUID string, profUUID string) error
// /////////////////////////////////////////////////////////////////////////////
// Secret variables
@@ -2124,6 +2128,19 @@ type Datastore interface {
// UpdateScimLastRequest updates the last SCIM request info
UpdateScimLastRequest(ctx context.Context, lastRequest *ScimLastRequest) error
// /////////////////////////////////////////////////////////////////////////////
// Challenges
// NewChallenge generates a random, base64-encoded challenge and inserts it into the challenges table.
NewChallenge(ctx context.Context) (string, error)
// ConsumeChallenge checks if a valid challenge exists in the challenges table
// and deletes it if it does. The error will include sql.ErrNoRows if the challenge
// is not found or is expired.
ConsumeChallenge(ctx context.Context, challenge string) error
// CleanupExpiredChallenges removes expired challenges from the challenges table,
// intended to be run as a cron job.
CleanupExpiredChallenges(ctx context.Context) (int64, error)
// /////////////////////////////////////////////////////////////////////////////
// Microsoft Compliance Partner
+3
View File
@@ -45,6 +45,9 @@ const (
FleetVarDigiCertPasswordPrefix = "DIGICERT_PASSWORD_" // nolint:gosec // G101: Potential hardcoded credentials
FleetVarCustomSCEPChallengePrefix = "CUSTOM_SCEP_CHALLENGE_"
FleetVarCustomSCEPProxyURLPrefix = "CUSTOM_SCEP_PROXY_URL_"
// OneTimeChallengeTTL is the time to live for one-time challenges.
OneTimeChallengeTTL = 1 * time.Hour
)
type AppleMDM struct {
+17
View File
@@ -33,19 +33,36 @@ type Service interface {
}
// ServiceWithIdentifier is the interface for all supported SCEP server operations.
// It extends the core SCEP server with ad hoc, polymorphic "identifier" functionality.
//
// FIXME: This seems to have been introduced as workaround to support Fleet-specific features
// but its usage in practice is non-intuitive. In the context of Fleet's SCEP proxy,
// the identifier has been implemented as comma-separated string that corresponds to various
// values used when processing SCEP requests, which vary based on the specific implementation
// (e.g., NDES vs. custom SCEP proxy). This functionality has been used to support
// ad hoc validation schemes.
type ServiceWithIdentifier interface {
// GetCACaps returns a list of options
// which are supported by the server.
//
// NOTE: See type definition of ServiceWithIdentifier
// for additional context on identifier usage.
GetCACaps(ctx context.Context, identifier string) ([]byte, error)
// GetCACert returns CA certificate or
// a CA certificate chain with intermediates
// in a PKCS#7 Degenerate Certificates format
// message is an optional string for the CA
//
// NOTE: See type definition of ServiceWithIdentifier
// for additional context on identifier usage.
GetCACert(ctx context.Context, message string, identifier string) ([]byte, int, error)
// PKIOperation handles incoming SCEP messages such as PKCSReq and
// sends back a CertRep PKIMessag.
//
// NOTE: See type definition of ServiceWithIdentifier
// for additional context on identifier usage.
PKIOperation(ctx context.Context, msg []byte, identifier string) ([]byte, error)
// GetNextCACert returns a replacement certificate or certificate chain
+3 -4
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
@@ -175,7 +174,7 @@ func message(r *http.Request) ([]byte, error) {
}
return []byte(msg), nil
case "POST":
return ioutil.ReadAll(io.LimitReader(r.Body, maxPayloadSize))
return io.ReadAll(io.LimitReader(r.Body, maxPayloadSize))
default:
return nil, errors.New("method not supported")
}
@@ -228,13 +227,13 @@ func encodeSCEPResponse(ctx context.Context, w http.ResponseWriter, response int
// DecodeSCEPResponse decodes a SCEP response
func DecodeSCEPResponse(ctx context.Context, r *http.Response) (interface{}, error) {
if r.StatusCode != http.StatusOK && r.StatusCode >= 400 {
body, _ := ioutil.ReadAll(io.LimitReader(r.Body, 4096))
body, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
return nil, fmt.Errorf("http request failed with status %s, msg: %s",
r.Status,
string(body),
)
}
data, err := ioutil.ReadAll(io.LimitReader(r.Body, maxPayloadSize))
data, err := io.ReadAll(io.LimitReader(r.Body, maxPayloadSize))
if err != nil {
return nil, err
}
+48
View File
@@ -1294,6 +1294,8 @@ type RenewMDMManagedCertificatesFunc func(ctx context.Context) error
type ListHostMDMManagedCertificatesFunc func(ctx context.Context, hostUUID string) ([]*fleet.MDMManagedCertificate, error)
type ResendHostCustomSCEPProfileFunc func(ctx context.Context, hostUUID string, profUUID string) error
type UpsertSecretVariablesFunc func(ctx context.Context, secretVariables []fleet.SecretVariable) error
type GetSecretVariablesFunc func(ctx context.Context, names []string) ([]fleet.SecretVariable, error)
@@ -1366,6 +1368,12 @@ type ScimLastRequestFunc func(ctx context.Context) (*fleet.ScimLastRequest, erro
type UpdateScimLastRequestFunc func(ctx context.Context, lastRequest *fleet.ScimLastRequest) error
type NewChallengeFunc func(ctx context.Context) (string, error)
type ConsumeChallengeFunc func(ctx context.Context, challenge string) error
type CleanupExpiredChallengesFunc func(ctx context.Context) (int64, error)
type ConditionalAccessMicrosoftCreateIntegrationFunc func(ctx context.Context, tenantID string, proxyServerSecret string) error
type ConditionalAccessMicrosoftGetFunc func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error)
@@ -3289,6 +3297,9 @@ type DataStore struct {
ListHostMDMManagedCertificatesFunc ListHostMDMManagedCertificatesFunc
ListHostMDMManagedCertificatesFuncInvoked bool
ResendHostCustomSCEPProfileFunc ResendHostCustomSCEPProfileFunc
ResendHostCustomSCEPProfileFuncInvoked bool
UpsertSecretVariablesFunc UpsertSecretVariablesFunc
UpsertSecretVariablesFuncInvoked bool
@@ -3397,6 +3408,15 @@ type DataStore struct {
UpdateScimLastRequestFunc UpdateScimLastRequestFunc
UpdateScimLastRequestFuncInvoked bool
NewChallengeFunc NewChallengeFunc
NewChallengeFuncInvoked bool
ConsumeChallengeFunc ConsumeChallengeFunc
ConsumeChallengeFuncInvoked bool
CleanupExpiredChallengesFunc CleanupExpiredChallengesFunc
CleanupExpiredChallengesFuncInvoked bool
ConditionalAccessMicrosoftCreateIntegrationFunc ConditionalAccessMicrosoftCreateIntegrationFunc
ConditionalAccessMicrosoftCreateIntegrationFuncInvoked bool
@@ -7873,6 +7893,13 @@ func (s *DataStore) ListHostMDMManagedCertificates(ctx context.Context, hostUUID
return s.ListHostMDMManagedCertificatesFunc(ctx, hostUUID)
}
func (s *DataStore) ResendHostCustomSCEPProfile(ctx context.Context, hostUUID string, profUUID string) error {
s.mu.Lock()
s.ResendHostCustomSCEPProfileFuncInvoked = true
s.mu.Unlock()
return s.ResendHostCustomSCEPProfileFunc(ctx, hostUUID, profUUID)
}
func (s *DataStore) UpsertSecretVariables(ctx context.Context, secretVariables []fleet.SecretVariable) error {
s.mu.Lock()
s.UpsertSecretVariablesFuncInvoked = true
@@ -8125,6 +8152,27 @@ func (s *DataStore) UpdateScimLastRequest(ctx context.Context, lastRequest *flee
return s.UpdateScimLastRequestFunc(ctx, lastRequest)
}
func (s *DataStore) NewChallenge(ctx context.Context) (string, error) {
s.mu.Lock()
s.NewChallengeFuncInvoked = true
s.mu.Unlock()
return s.NewChallengeFunc(ctx)
}
func (s *DataStore) ConsumeChallenge(ctx context.Context, challenge string) error {
s.mu.Lock()
s.ConsumeChallengeFuncInvoked = true
s.mu.Unlock()
return s.ConsumeChallengeFunc(ctx, challenge)
}
func (s *DataStore) CleanupExpiredChallenges(ctx context.Context) (int64, error) {
s.mu.Lock()
s.CleanupExpiredChallengesFuncInvoked = true
s.mu.Unlock()
return s.CleanupExpiredChallengesFunc(ctx)
}
func (s *DataStore) ConditionalAccessMicrosoftCreateIntegration(ctx context.Context, tenantID string, proxyServerSecret string) error {
s.mu.Lock()
s.ConditionalAccessMicrosoftCreateIntegrationFuncInvoked = true
+6 -1
View File
@@ -4510,9 +4510,14 @@ func preprocessProfileContents(
"This error should never happen since we validated/populated CAs earlier", "ca_name", caName)
continue
}
// Generate a new SCEP challenge for the profile
challenge, err := ds.NewChallenge(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating SCEP challenge")
}
// 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,%s", hostUUID, profUUID, caName)))
url.PathEscape(fmt.Sprintf("%s,%s,%s,%s", hostUUID, profUUID, caName, challenge)))
hostContents, err = replaceExactFleetPrefixVariableInXML(fleet.FleetVarCustomSCEPProxyURLPrefix, ca.Name, hostContents, proxyURL)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing Fleet variable for SCEP proxy URL")
+277 -53
View File
@@ -14734,6 +14734,99 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() {
scepServer := scep_server.StartTestSCEPServer(t)
scepServerURL := scepServer.URL + "/scep"
// parseSCEPProfile returns the parsed SCEP profile along with the identifier from the profile URL
parseSCEPProfile := func(t *testing.T, raw []byte, scepConfig fleet.CustomSCEPProxyIntegration) (SCEPProfileContent, string) {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(raw, &fullCmd))
require.NotNil(t, fullCmd.Command)
require.NotNil(t, fullCmd.Command.InstallProfile)
rawProfile := fullCmd.Command.InstallProfile.Payload
if !bytes.HasPrefix(rawProfile, []byte("<?xml")) {
p7, err := pkcs7.Parse(rawProfile)
require.NoError(t, err)
require.NoError(t, p7.Verify())
rawProfile = p7.Content
}
var scepProfile SCEPProfileContent
require.NoError(t, plist.Unmarshal(rawProfile, &scepProfile))
require.Equal(t, "com.apple.security.scep", scepProfile.PayloadContent[0].PayloadType)
require.Equal(t, scepConfig.Challenge, scepProfile.PayloadContent[0].PayloadContent.Challenge)
expectBaseURL := s.server.URL + apple_mdm.SCEPProxyPath
require.True(t, strings.HasPrefix(scepProfile.PayloadContent[0].PayloadContent.URL, expectBaseURL))
identifier := strings.TrimPrefix(scepProfile.PayloadContent[0].PayloadContent.URL, expectBaseURL)
return scepProfile, identifier
}
// parseIdentifier is a helper function to parse the identifier from the SCEP profile and check
// components of the identifier against expected values. It returns the SCEP challenge.
parseIdentifier := func(t *testing.T, identifier, wantHostUUID, wantProfUUID, wantSCEPName string) string {
parts := strings.Split(identifier, url.PathEscape(","))
require.Len(t, parts, 4)
require.Equal(t, wantHostUUID, parts[0])
require.Equal(t, wantProfUUID, parts[1])
require.Equal(t, wantSCEPName, parts[2])
gotChallenge := parts[3]
require.NotEmpty(t, gotChallenge, "Challenge should not be empty in the identifier")
return gotChallenge
}
// checkChallenge is a helper function to check if the challenge exists in the database.
checkChallenge := func(t *testing.T, challenge string, expectFound bool) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var foundChallenge string
stmt := "SELECT challenge FROM challenges where challenge = ?"
err := sqlx.GetContext(context.Background(), q, &foundChallenge, stmt, challenge)
switch {
case errors.Is(err, sql.ErrNoRows):
require.False(t, expectFound)
require.Empty(t, foundChallenge)
return nil
default:
require.NoError(t, err, "Failed to check challenge existence in the database")
require.True(t, expectFound, fmt.Sprintf("found challenge %s, but expected not to find it", foundChallenge))
require.NotEmpty(t, foundChallenge, "challenge should not be empty in the database")
return nil
}
})
}
// checkProfileInstallStatus is a helper function to check the status of a profile in the host
// response. It asserts that the profile with the given name exists, checks its status,
// operation type, and detail. It returns the profile UUID for further checks.
checkProfileInstallStatus := func(t *testing.T, hostResp getDeviceHostResponse, profileName string, wantStatus fleet.MDMDeliveryStatus, wantDetail string) string {
var found bool
require.NotNil(t, hostResp.Host.MDM.Profiles)
var profileUUID string
for _, prof := range *hostResp.Host.MDM.Profiles {
if prof.Name == profileName {
found = true
require.Equal(t, wantStatus, *prof.Status)
require.Equal(t, fleet.MDMOperationTypeInstall, prof.OperationType)
require.Contains(t, prof.Detail, wantDetail)
profileUUID = prof.ProfileUUID
break
}
}
require.True(t, found)
return profileUUID
}
// verifySCEPProfile is a helper function to verify the SCEP profile in the host profiles.
verifySCEPProfile := func(t *testing.T, host *fleet.Host, hostProf fleet.HostMacOSProfile, wantProfUUID string, wantCAName string) {
hostProfs := map[string]*fleet.HostMacOSProfile{
hostProf.Identifier: &hostProf,
}
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, host, hostProfs))
prof, err := s.ds.GetHostMDMCertificateProfile(context.Background(), host.UUID, wantProfUUID, wantCAName)
require.NoError(t, err)
require.NotNil(t, prof)
require.Equal(t, wantCAName, prof.CAName)
require.Equal(t, fleet.CAConfigCustomSCEPProxy, prof.Type)
require.Equal(t, fleet.MDMDeliveryVerified, *prof.Status)
}
// Create a host and then enroll to MDM.
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
@@ -14797,42 +14890,15 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() {
s.awaitTriggerProfileSchedule(t)
getHostResp := getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
var found bool
require.NotNil(t, getHostResp.Host.MDM.Profiles)
var profileUUID string
for _, prof := range *getHostResp.Host.MDM.Profiles {
if prof.Name == "N0" {
found = true
assert.Equal(t, fleet.MDMDeliveryPending, *prof.Status)
assert.Equal(t, fleet.MDMOperationTypeInstall, prof.OperationType)
assert.Empty(t, prof.Detail)
profileUUID = prof.ProfileUUID
break
}
}
assert.True(t, found)
profileUUID := checkProfileInstallStatus(t, getHostResp, "N0", fleet.MDMDeliveryPending, "")
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "Expecting SCEP profile")
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
require.NotNil(t, fullCmd.Command)
require.NotNil(t, fullCmd.Command.InstallProfile)
rawProfile := fullCmd.Command.InstallProfile.Payload
if !bytes.HasPrefix(rawProfile, []byte("<?xml")) {
p7, err := pkcs7.Parse(rawProfile)
require.NoError(t, err)
require.NoError(t, p7.Verify())
rawProfile = p7.Content
}
var scepProfile SCEPProfileContent
require.NoError(t, plist.Unmarshal(rawProfile, &scepProfile))
assert.Equal(t, "com.apple.security.scep", scepProfile.PayloadContent[0].PayloadType)
assert.Equal(t, ca0.Challenge, scepProfile.PayloadContent[0].PayloadContent.Challenge)
identifier := url.PathEscape(host.UUID + "," + profileUUID + "," + "scepName")
assert.Equal(t, s.server.URL+apple_mdm.SCEPProxyPath+identifier, scepProfile.PayloadContent[0].PayloadContent.URL)
_, identifier := parseSCEPProfile(t, cmd.Raw, ca0)
gotChallenge := parseIdentifier(t, identifier, host.UUID, profileUUID, "scepName")
checkChallenge(t, gotChallenge, true)
// /////////////////////////////////////
// Test SCEP traffic being sent by host
@@ -14841,6 +14907,8 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() {
body, err := io.ReadAll(scepRes.Body)
require.NoError(t, err)
assert.Equal(t, scepserver.DefaultCACaps, string(body))
// Check that the challenge is still in the database (only deleted after PKIOperation)
checkChallenge(t, gotChallenge, true)
// GetCACert
scepRes = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier, nil, http.StatusOK, nil, "operation", "GetCACert")
@@ -14849,6 +14917,8 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() {
certs, err := x509.ParseCertificates(body)
require.NoError(t, err)
assert.Len(t, certs, 1)
// Check that the challenge is still in the database (only deleted after PKIOperation)
checkChallenge(t, gotChallenge, true)
// PKIOperation
data, err := os.ReadFile("./testdata/PKCSReq.der")
@@ -14864,21 +14934,185 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() {
pkiMessage, err := scep.ParsePKIMessage(body, scep.WithCACerts(certs))
require.NoError(t, err)
assert.Equal(t, scep.CertRep, pkiMessage.MessageType)
// Check that the challenge is removed from the database after PKIOperation
checkChallenge(t, gotChallenge, false)
// Host acknowledges the profile and we mark it as verified
// Host acknowledges the profile and it is marked as verifying
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
assert.Nil(t, cmd)
hostProfs := map[string]*fleet.HostMacOSProfile{
"I0": {Identifier: "I0", DisplayName: "N0", InstallDate: time.Now()},
}
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, host, hostProfs))
prof, err := s.ds.GetHostMDMCertificateProfile(context.Background(), host.UUID, profileUUID, "scepName")
getHostResp = getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
profileUUIDTest := checkProfileInstallStatus(t, getHostResp, "N0", fleet.MDMDeliveryVerifying, "")
require.Equal(t, profileUUID, profileUUIDTest, "Expected the same profile UUID after re-sending the SCEP profile")
// Try again, it should fail because the profile is not pending
_ = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier, nil, http.StatusBadRequest, nil, "operation",
"PKIOperation", "message", message)
// Failed challenge doesn't resend the SCEP profile unless it is pending
s.awaitTriggerProfileSchedule(t)
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, prof)
assert.Equal(t, "scepName", prof.CAName)
assert.Equal(t, fleet.CAConfigCustomSCEPProxy, prof.Type)
assert.Equal(t, fleet.MDMDeliveryVerified, *prof.Status)
require.Nil(t, cmd, "Not expecting SCEP profile")
// Mark the profile as pending so that it can be resent
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := "UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ? AND profile_uuid = ?"
r, err := q.ExecContext(context.Background(), stmt, nil, host.UUID, profileUUID)
require.NoError(t, err, "Failed to update profile status in the database")
rowsAffected, _ := r.RowsAffected()
require.Equal(t, int64(1), rowsAffected, "Expected to update 1 row for the profile status")
return nil
})
// Try to do SCEP with deleted challenge, it will fail because challenge is no longer in the datastore
// but a new challenge will be generated and the profile resent
_ = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier, nil, http.StatusBadRequest, nil, "operation",
"PKIOperation", "message", message)
// Check profile status, raw status will be nil (awaiting the reconcile job)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status string
stmt := "SELECT coalesce(status, '') FROM host_mdm_apple_profiles WHERE host_uuid = ? AND profile_uuid = ?"
err := sqlx.GetContext(context.Background(), q, &status, stmt, host.UUID, profileUUID)
require.NoError(t, err)
require.Empty(t, status, "Expected profile status to be nil after re-sending the SCEP profile")
return nil
})
// Reconcile profiles, raw status becomes pending
s.awaitTriggerProfileSchedule(t)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status string
stmt := "SELECT coalesce(status, '') FROM host_mdm_apple_profiles WHERE host_uuid = ? AND profile_uuid = ?"
err := sqlx.GetContext(context.Background(), q, &status, stmt, host.UUID, profileUUID)
require.NoError(t, err)
require.Equal(t, string(fleet.MDMDeliveryPending), status, "Expected profile status to be pending after re-sending the SCEP profile")
return nil
})
// Device checks in again and should receive the SCEP profile
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "Expecting SCEP profile")
// Status should still be pending because we haven't acknowledged the profile command yet
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status string
stmt := "SELECT coalesce(status, '') FROM host_mdm_apple_profiles WHERE host_uuid = ? AND profile_uuid = ?"
err := sqlx.GetContext(context.Background(), q, &status, stmt, host.UUID, profileUUID)
require.NoError(t, err)
require.Equal(t, string(fleet.MDMDeliveryPending), status, "Expected profile status to be pending after re-sending the SCEP profile")
return nil
})
// Identifier should be different, and the challenge should be different
_, identifier2 := parseSCEPProfile(t, cmd.Raw, ca0)
require.NotEqual(t, identifier, identifier2, "Expected a different identifier after re-sending the SCEP profile")
gotChallenge2 := parseIdentifier(t, identifier2, host.UUID, profileUUID, "scepName")
require.NotEqual(t, gotChallenge, gotChallenge2, "Expected a new challenge after re-sending the SCEP profile")
checkChallenge(t, gotChallenge, false) // old challenge deleted
checkChallenge(t, gotChallenge2, true) // new challenge added
// Check the host details
getHostResp = getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
profileUUID2 := checkProfileInstallStatus(t, getHostResp, "N0", fleet.MDMDeliveryPending, "")
require.Equal(t, profileUUID, profileUUID2, "Expected the same profile UUID after re-sending the SCEP profile")
// Expire the challenge so that the next PKIOperation fails
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := "UPDATE challenges SET created_at = ? WHERE challenge = ?"
res, err := q.ExecContext(context.Background(), stmt, time.Now().Add(-2*time.Hour), gotChallenge2)
require.NoError(t, err, "Failed to expire the challenge in the database")
rowsAffected, _ := res.RowsAffected()
require.Equal(t, int64(1), rowsAffected, "Expected to update 1 row for the challenge")
return nil
})
// Do SCEP again, it should fail because the challenge is expired but a new challenge should be
// generated and the profile resent
_ = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier2, nil, http.StatusBadRequest, nil, "operation",
"PKIOperation", "message", message)
// Check profile status, raw status will be empty (awaiting the reconcile job)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status string
stmt := "SELECT coalesce(status, '') FROM host_mdm_apple_profiles WHERE host_uuid = ? AND profile_uuid = ?"
err := sqlx.GetContext(context.Background(), q, &status, stmt, host.UUID, profileUUID)
require.NoError(t, err)
require.Empty(t, status, "Expected profile status to be empty after re-sending the SCEP profile")
return nil
})
// Check hosts details, derived status should still be pending
getHostResp = getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
profileUUID2 = checkProfileInstallStatus(t, getHostResp, "N0", fleet.MDMDeliveryPending, "")
require.Equal(t, profileUUID, profileUUID2, "Expected the same profile UUID after re-sending the SCEP profile")
// Reconcile profiles, raw status becomes pending
s.awaitTriggerProfileSchedule(t)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status string
stmt := "SELECT coalesce(status, '') FROM host_mdm_apple_profiles WHERE host_uuid = ? AND profile_uuid = ?"
err := sqlx.GetContext(context.Background(), q, &status, stmt, host.UUID, profileUUID)
require.NoError(t, err)
require.Equal(t, string(fleet.MDMDeliveryPending), status, "Expected profile status to be pending after re-sending the SCEP profile")
return nil
})
// Device checks in again and should receive the SCEP profile
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "Expecting SCEP profile")
// Status should still be pending because we haven't acknowledged the profile command yet
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status string
stmt := "SELECT coalesce(status, '') FROM host_mdm_apple_profiles WHERE host_uuid = ? AND profile_uuid = ?"
err := sqlx.GetContext(context.Background(), q, &status, stmt, host.UUID, profileUUID)
require.NoError(t, err)
require.Equal(t, string(fleet.MDMDeliveryPending), status, "Expected profile status to be pending after re-sending the SCEP profile")
return nil
})
// Identifier should be different, and the challenge should be different
_, identifier3 := parseSCEPProfile(t, cmd.Raw, ca0)
require.NotEqual(t, identifier2, identifier3, "Expected a different identifier after re-sending the SCEP profile")
gotChallenge3 := parseIdentifier(t, identifier3, host.UUID, profileUUID, "scepName")
require.NotEqual(t, gotChallenge2, gotChallenge3, "Expected a new challenge after re-sending the SCEP profile")
checkChallenge(t, gotChallenge, false) // old challenge deleted
checkChallenge(t, gotChallenge2, false) // old challenge deleted
checkChallenge(t, gotChallenge3, true) // new challenge added
scepRes = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier3, nil, http.StatusOK, nil, "operation",
"PKIOperation", "message", message)
body, err = io.ReadAll(scepRes.Body)
require.NoError(t, err)
pkiMessage, err = scep.ParsePKIMessage(body, scep.WithCACerts(certs))
require.NoError(t, err)
assert.Equal(t, scep.CertRep, pkiMessage.MessageType)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
assert.Nil(t, cmd)
getHostResp = getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
profileUUID3 := checkProfileInstallStatus(t, getHostResp, "N0", fleet.MDMDeliveryVerifying, "")
require.Equal(t, profileUUID2, profileUUID3, "Expected the same profile UUID after acknowledging the SCEP profile")
verifySCEPProfile(t, host, fleet.HostMacOSProfile{
Identifier: "I0",
DisplayName: "N0",
InstallDate: time.Now(),
}, profileUUID, "scepName")
getHostResp = getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
profileUUID4 := checkProfileInstallStatus(t, getHostResp, "N0", fleet.MDMDeliveryVerified, "")
require.Equal(t, profileUUID3, profileUUID4, "Expected the same profile UUID after verifying the SCEP profile")
// No more commands pending
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// ////////////////////////////////////////////
// Remove the CAs and try to re-send the profile
@@ -14898,18 +15132,8 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() {
s.awaitTriggerProfileSchedule(t)
getHostResp = getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
found = false
require.NotNil(t, getHostResp.Host.MDM.Profiles)
for _, prof := range *getHostResp.Host.MDM.Profiles {
if prof.Name == "N0" {
found = true
assert.Equal(t, fleet.MDMDeliveryFailed, *prof.Status)
assert.Equal(t, fleet.MDMOperationTypeInstall, prof.OperationType)
assert.Contains(t, prof.Detail, "scepName certificate authority doesn't exist")
break
}
}
assert.True(t, found)
checkProfileInstallStatus(t, getHostResp, "N0", fleet.MDMDeliveryFailed,
"scepName certificate authority doesn't exist")
}
func (s *integrationMDMTestSuite) TestVPPAppsMDMFiltering() {