Add one-time challenge support to custom SCEP proxy (#29832)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Updated custom SCEP proxy implementation to include one-time challenges.
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user