diff --git a/ee/server/service/scep_proxy.go b/ee/server/service/scep_proxy.go index 21cd67ea57..7445e739f1 100644 --- a/ee/server/service/scep_proxy.go +++ b/ee/server/service/scep_proxy.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "strings" "time" @@ -39,6 +40,81 @@ const ( SmallstepChallengeInvalidAfter = 4 * time.Minute ) +// scepCertificateRequest abstracts the common operations needed for SCEP certificate +// requests across different platforms (Apple, Windows, Android). +type scepCertificateRequest interface { + // GetStatus returns the delivery status of the certificate request. + // Returns empty string if status is not set. + GetStatus() fleet.MDMDeliveryStatus + // GetChallengeRetrievedAt returns when the challenge was retrieved (for expiration checks). + GetChallengeRetrievedAt() *time.Time + // GetCAType returns the certificate authority type (NDES, Smallstep, CustomSCEPProxy). + GetCAType() fleet.CAConfigAssetType + // GetCAName returns the name of the certificate authority. + GetCAName() string + // GetProfileUUID returns the profile/template UUID. + GetProfileUUID() string +} + +// hostMDMCertificateProfileAdapter adapts HostMDMCertificateProfile to scepCertificateRequest. +type hostMDMCertificateProfileAdapter struct { + profile *fleet.HostMDMCertificateProfile +} + +func (a *hostMDMCertificateProfileAdapter) GetStatus() fleet.MDMDeliveryStatus { + if a.profile.Status == nil { + return "" + } + return *a.profile.Status +} + +func (a *hostMDMCertificateProfileAdapter) GetChallengeRetrievedAt() *time.Time { + return a.profile.ChallengeRetrievedAt +} + +func (a *hostMDMCertificateProfileAdapter) GetCAType() fleet.CAConfigAssetType { + return a.profile.Type +} + +func (a *hostMDMCertificateProfileAdapter) GetCAName() string { + return a.profile.CAName +} + +func (a *hostMDMCertificateProfileAdapter) GetProfileUUID() string { + return a.profile.ProfileUUID +} + +// certificateTemplateForHostAdapter adapts CertificateTemplateForHost to scepCertificateRequest. +type certificateTemplateForHostAdapter struct { + template *fleet.CertificateTemplateForHost + profileUUID string +} + +func (a *certificateTemplateForHostAdapter) GetStatus() fleet.MDMDeliveryStatus { + if a.template.Status == nil { + return "" + } + return *a.template.Status +} + +func (a *certificateTemplateForHostAdapter) GetChallengeRetrievedAt() *time.Time { + // Android certificate templates don't track challenge retrieval time; + // they use one-time fleet challenges validated via ConsumeChallenge. + return nil +} + +func (a *certificateTemplateForHostAdapter) GetCAType() fleet.CAConfigAssetType { + return a.template.CAType +} + +func (a *certificateTemplateForHostAdapter) GetCAName() string { + return a.template.CAName +} + +func (a *certificateTemplateForHostAdapter) GetProfileUUID() string { + return a.profileUUID +} + type scepProxyService struct { ds fleet.Datastore // info logging is implemented in the service middleware layer. @@ -145,33 +221,69 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier if len(parsedIDs) > 3 { fleetChallenge = parsedIDs[3] } + if !strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix) && - !strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix) { - return "", &scepserver.BadRequestError{Message: fmt.Sprintf("invalid profile UUID (only Apple and Windows config profiles are supported): %s", + !strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix) && + !strings.HasPrefix(profileUUID, fleet.MDMAndroidProfileUUIDPrefix) { + return "", &scepserver.BadRequestError{Message: fmt.Sprintf("invalid profile UUID (only Apple, Windows, and Android config profiles are supported): %s", profileUUID)} } - var profile *fleet.HostMDMCertificateProfile - if strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix) { - profile, err = svc.ds.GetAppleHostMDMCertificateProfile(ctx, hostUUID, profileUUID, caName) - } else if strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix) { - profile, err = svc.ds.GetWindowsHostMDMCertificateProfile(ctx, hostUUID, profileUUID, caName) - } - if err != nil { - return "", ctxerr.Wrap(ctx, err, "getting host MDM profile") + var certReq scepCertificateRequest + + switch { + case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix): + profile, err := svc.ds.GetAppleHostMDMCertificateProfile(ctx, hostUUID, profileUUID, caName) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "getting host MDM profile") + } + if profile != nil { + certReq = &hostMDMCertificateProfileAdapter{profile: profile} + } + + case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix): + profile, err := svc.ds.GetWindowsHostMDMCertificateProfile(ctx, hostUUID, profileUUID, caName) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "getting host MDM profile") + } + if profile != nil { + certReq = &hostMDMCertificateProfileAdapter{profile: profile} + } + + case strings.HasPrefix(profileUUID, fleet.MDMAndroidProfileUUIDPrefix): + // Android identifier format: {hostUUID},g{certificateTemplateID},{caType},{challenge} + // Parse the certificate template ID from the profileUUID (e.g., "g123" -> 123) + certTemplateIDStr := strings.TrimPrefix(profileUUID, fleet.MDMAndroidProfileUUIDPrefix) + certTemplateID, err := strconv.ParseUint(certTemplateIDStr, 10, 32) + if err != nil { + return "", &scepserver.BadRequestError{Message: fmt.Sprintf("invalid Android certificate template ID: %s", certTemplateIDStr)} + } + + template, err := svc.ds.GetCertificateTemplateForHost(ctx, hostUUID, uint(certTemplateID)) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "getting Android certificate template") + } + certReq = &certificateTemplateForHostAdapter{ + template: template, + profileUUID: profileUUID, + } + // Use the fleet challenge from the template if not provided in the identifier + if fleetChallenge == "" && template.FleetChallenge != nil { + fleetChallenge = *template.FleetChallenge + } } - if profile == nil { + if certReq == nil { // Return error that implements kithttp.StatusCoder interface return "", &scepserver.BadRequestError{Message: "unknown identifier in URL path"} } // We skip windows profiles for this check here as they instantly go to verifying when sent out, might change for windows renewal. - if (profile.Status == nil || *profile.Status != fleet.MDMDeliveryPending) && !strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix) { + if certReq.GetStatus() != fleet.MDMDeliveryPending && !strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix) { // This could happen if Fleet DB was updated before the profile was updated on the host. // We expect another certificate request from the host once the profile is updated. - status := "null" - if profile.Status != nil { - status = string(*profile.Status) + status := certReq.GetStatus() + if status == "" { + status = "null" } // FIXME: MDM client will report a failed status for the profile when we return bad request, which consumes the sole retry attempt. // Seems like we should proactively use ResendHostCertificateProfile here too? @@ -180,17 +292,18 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier } var scepURL string - switch profile.Type { + switch certReq.GetCAType() { case fleet.CAConfigNDES: if groupedCAs.NDESSCEP == nil { // Return error that implements kithttp.StatusCoder interface return "", &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured} } - if checkChallenge && profile.ChallengeRetrievedAt != nil && profile.ChallengeRetrievedAt.Add(NDESChallengeInvalidAfter).Before(time.Now()) { + challengeRetrievedAt := certReq.GetChallengeRetrievedAt() + if checkChallenge && challengeRetrievedAt != nil && challengeRetrievedAt.Add(NDESChallengeInvalidAfter).Before(time.Now()) { // The challenge password was retrieved for this profile, and is now invalid. // We need to resend the profile with a new challenge password. // Note: we don't actually know if it is invalid, and we can't get that exact feedback from SCEP server. - if err = svc.ds.ResendHostMDMProfile(ctx, hostUUID, profileUUID); err != nil { + if err := svc.ds.ResendHostMDMProfile(ctx, hostUUID, profileUUID); err != nil { return "", ctxerr.Wrap(ctx, err, "resending host mdm profile") } return "", &scepserver.BadRequestError{Message: "challenge password has expired"} @@ -202,18 +315,19 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier return "", &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured} } for _, ca := range groupedCAs.Smallstep { - if ca.Name == profile.CAName { + if ca.Name == certReq.GetCAName() { scepURL = ca.URL break } } // FIXME: See comment in datastore method regarding how we resend profiles with dynamic content - if checkChallenge && profile.ChallengeRetrievedAt != nil && profile.ChallengeRetrievedAt.Add(SmallstepChallengeInvalidAfter).Before(time.Now()) { + challengeRetrievedAt := certReq.GetChallengeRetrievedAt() + if checkChallenge && challengeRetrievedAt != nil && challengeRetrievedAt.Add(SmallstepChallengeInvalidAfter).Before(time.Now()) { // The challenge password was retrieved for this profile, and is now invalid. // We need to resend the profile with a new challenge password. // Note: we don't actually know if it is invalid, and we can't get that exact feedback from SCEP server. - if err = svc.ds.ResendHostCertificateProfile(ctx, hostUUID, profileUUID); err != nil { + if err := svc.ds.ResendHostCertificateProfile(ctx, hostUUID, profileUUID); err != nil { return "", ctxerr.Wrap(ctx, err, "resending host mdm profile") } return "", &scepserver.BadRequestError{Message: "challenge password has expired"} @@ -224,13 +338,13 @@ func (svc *scepProxyService) validateIdentifier(ctx context.Context, identifier return "", &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured} } for _, ca := range groupedCAs.CustomScepProxy { - if ca.Name == profile.CAName { + if ca.Name == certReq.GetCAName() { scepURL = ca.URL break } } - if strings.HasPrefix(profile.ProfileUUID, fleet.MDMWindowsProfileUUIDPrefix) { + if strings.HasPrefix(certReq.GetProfileUUID(), fleet.MDMWindowsProfileUUIDPrefix) { // TODO: Early return for Windows profiles as they do not support resending yet. return scepURL, nil } diff --git a/ee/server/service/scep_proxy_test.go b/ee/server/service/scep_proxy_test.go index f19705fd31..eaf2ce9b3b 100644 --- a/ee/server/service/scep_proxy_test.go +++ b/ee/server/service/scep_proxy_test.go @@ -2,14 +2,18 @@ package service import ( "context" + "database/sql" "encoding/binary" + "errors" "net/http" "net/http/httptest" + "net/url" "os" "testing" "time" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" kitlog "github.com/go-kit/log" "github.com/stretchr/testify/assert" @@ -113,3 +117,928 @@ func TestValidateSCEPURL(t *testing.T) { err = svc.ValidateSCEPURL(context.Background(), proxy.URL) assert.ErrorContains(t, err, "could not retrieve CA certificate") } + +func TestValidateIdentifier(t *testing.T) { + t.Parallel() + + ctx := context.Background() + logger := kitlog.NewNopLogger() + + // Helper to create a scepProxyService with a mock datastore + newTestService := func(ds *mock.DataStore) *scepProxyService { + return &scepProxyService{ + ds: ds, + debugLogger: logger, + Timeout: ptr.Duration(30 * time.Second), + } + } + + // Helper to create a valid identifier + makeIdentifier := func(hostUUID, profileUUID, caName, challenge string) string { + id := hostUUID + "," + profileUUID + if caName != "" { + id += "," + caName + } + if challenge != "" { + id += "," + challenge + } + return url.PathEscape(id) + } + + t.Run("identifier parsing errors", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + svc := newTestService(ds) + + testCases := []struct { + name string + identifier string + errMsg string + }{ + { + name: "empty identifier", + identifier: "", + errMsg: "invalid identifier in URL path", + }, + { + name: "single element", + identifier: "host-uuid-only", + errMsg: "invalid identifier in URL path", + }, + { + name: "empty host UUID", + identifier: makeIdentifier("", "a-profile-uuid", "", ""), + errMsg: "invalid identifier in URL path", + }, + { + name: "empty profile UUID", + identifier: makeIdentifier("host-uuid", "", "", ""), + errMsg: "invalid identifier in URL path", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.validateIdentifier(ctx, tc.identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errMsg) + }) + } + }) + + t.Run("invalid profile UUID prefix", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + svc := newTestService(ds) + + // Profile UUID must start with "a" (Apple) or "w" (Windows) + identifier := makeIdentifier("host-uuid", "invalid-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid profile UUID") + }) + + t.Run("profile not found", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return nil, nil // Profile not found + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown identifier in URL path") + }) + + t.Run("profile status not pending for Apple profile", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{URL: "https://ndes.example.com/scep"}, + }, nil + } + verifiedStatus := fleet.MDMDeliveryVerified + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &verifiedStatus, + Type: fleet.CAConfigNDES, + CAName: caName, + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "profile status (verified) is not 'pending'") + }) + + t.Run("Windows profile skips status check", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "test-ca", URL: "https://scep.example.com/scep"}, + }, + }, nil + } + verifiedStatus := fleet.MDMDeliveryVerified + ds.GetWindowsHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &verifiedStatus, // Not pending, but should pass for Windows + Type: fleet.CAConfigCustomSCEPProxy, + CAName: "test-ca", + }, nil + } + svc := newTestService(ds) + + // Windows profiles skip status check + identifier := makeIdentifier("host-uuid", "w-profile-uuid", "test-ca", "") + scepURL, err := svc.validateIdentifier(ctx, identifier, false) + require.NoError(t, err) + assert.Equal(t, "https://scep.example.com/scep", scepURL) + }) + + t.Run("NDES CA not configured", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + NDESSCEP: nil, // NDES not configured + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigNDES, + CAName: "NDES", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), MessageSCEPProxyNotConfigured) + }) + + t.Run("NDES valid request without challenge check", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{URL: "https://ndes.example.com/scep"}, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigNDES, + CAName: "NDES", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + scepURL, err := svc.validateIdentifier(ctx, identifier, false) + require.NoError(t, err) + assert.Equal(t, "https://ndes.example.com/scep", scepURL) + }) + + t.Run("NDES challenge expired triggers requeue", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{URL: "https://ndes.example.com/scep"}, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + expiredTime := time.Now().Add(-58 * time.Minute) // Expired (>57 minutes) + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigNDES, + CAName: "NDES", + ChallengeRetrievedAt: &expiredTime, + }, nil + } + ds.ResendHostMDMProfileFunc = func(ctx context.Context, hostUUID, profileUUID string) error { + assert.Equal(t, "host-uuid", hostUUID) + assert.Equal(t, "a-profile-uuid", profileUUID) + return nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, true) // checkChallenge=true + require.Error(t, err) + assert.Contains(t, err.Error(), "challenge password has expired") + assert.True(t, ds.ResendHostMDMProfileFuncInvoked) + ds.ResendHostMDMProfileFuncInvoked = false + }) + + t.Run("NDES challenge not expired", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{URL: "https://ndes.example.com/scep"}, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + recentTime := time.Now().Add(-30 * time.Minute) // Not expired (<57 minutes) + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigNDES, + CAName: "NDES", + ChallengeRetrievedAt: &recentTime, + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + scepURL, err := svc.validateIdentifier(ctx, identifier, true) + require.NoError(t, err) + assert.Equal(t, "https://ndes.example.com/scep", scepURL) + }) + + t.Run("Smallstep CA not configured", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + Smallstep: []fleet.SmallstepSCEPProxyCA{}, // Empty + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigSmallstep, + CAName: "my-smallstep", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-smallstep", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), MessageSCEPProxyNotConfigured) + }) + + t.Run("Smallstep valid request", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + Smallstep: []fleet.SmallstepSCEPProxyCA{ + {Name: "my-smallstep", URL: "https://smallstep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigSmallstep, + CAName: "my-smallstep", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-smallstep", "") + scepURL, err := svc.validateIdentifier(ctx, identifier, false) + require.NoError(t, err) + assert.Equal(t, "https://smallstep.example.com/scep", scepURL) + }) + + t.Run("Smallstep challenge expired triggers requeue", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + Smallstep: []fleet.SmallstepSCEPProxyCA{ + {Name: "my-smallstep", URL: "https://smallstep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + expiredTime := time.Now().Add(-5 * time.Minute) // Expired (>4 minutes) + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigSmallstep, + CAName: "my-smallstep", + ChallengeRetrievedAt: &expiredTime, + }, nil + } + ds.ResendHostCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID string) error { + assert.Equal(t, "host-uuid", hostUUID) + assert.Equal(t, "a-profile-uuid", profileUUID) + return nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-smallstep", "") + _, err := svc.validateIdentifier(ctx, identifier, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "challenge password has expired") + assert.True(t, ds.ResendHostCertificateProfileFuncInvoked) + ds.ResendHostCertificateProfileFuncInvoked = false + }) + + t.Run("Smallstep challenge not expired", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + Smallstep: []fleet.SmallstepSCEPProxyCA{ + {Name: "my-smallstep", URL: "https://smallstep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + recentTime := time.Now().Add(-2 * time.Minute) // Not expired (<4 minutes) + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigSmallstep, + CAName: "my-smallstep", + ChallengeRetrievedAt: &recentTime, + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-smallstep", "") + scepURL, err := svc.validateIdentifier(ctx, identifier, true) + require.NoError(t, err) + assert.Equal(t, "https://smallstep.example.com/scep", scepURL) + }) + + t.Run("Custom SCEP CA not configured", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{}, // Empty + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigCustomSCEPProxy, + CAName: "my-custom-ca", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-custom-ca", "test-challenge") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), MessageSCEPProxyNotConfigured) + }) + + t.Run("Custom SCEP valid request without challenge check", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "my-custom-ca", URL: "https://custom-scep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigCustomSCEPProxy, + CAName: "my-custom-ca", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-custom-ca", "test-challenge") + scepURL, err := svc.validateIdentifier(ctx, identifier, false) + require.NoError(t, err) + assert.Equal(t, "https://custom-scep.example.com/scep", scepURL) + }) + + t.Run("Custom SCEP valid challenge consumption", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "my-custom-ca", URL: "https://custom-scep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigCustomSCEPProxy, + CAName: "my-custom-ca", + }, nil + } + ds.ConsumeChallengeFunc = func(ctx context.Context, challenge string) error { + assert.Equal(t, "valid-challenge", challenge) + return nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-custom-ca", "valid-challenge") + scepURL, err := svc.validateIdentifier(ctx, identifier, true) + require.NoError(t, err) + assert.Equal(t, "https://custom-scep.example.com/scep", scepURL) + assert.True(t, ds.ConsumeChallengeFuncInvoked) + ds.ConsumeChallengeFuncInvoked = false + }) + + t.Run("Custom SCEP invalid challenge triggers requeue", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "my-custom-ca", URL: "https://custom-scep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigCustomSCEPProxy, + CAName: "my-custom-ca", + }, nil + } + ds.ConsumeChallengeFunc = func(ctx context.Context, challenge string) error { + return sql.ErrNoRows // Challenge not found + } + ds.ResendHostCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID string) error { + assert.Equal(t, "host-uuid", hostUUID) + assert.Equal(t, "a-profile-uuid", profileUUID) + return nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-custom-ca", "invalid-challenge") + _, err := svc.validateIdentifier(ctx, identifier, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "custom scep challenge failed") + assert.True(t, ds.ConsumeChallengeFuncInvoked) + assert.True(t, ds.ResendHostCertificateProfileFuncInvoked) + ds.ConsumeChallengeFuncInvoked = false + ds.ResendHostCertificateProfileFuncInvoked = false + }) + + t.Run("Custom SCEP Windows profile skips challenge check", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "my-custom-ca", URL: "https://custom-scep.example.com/scep"}, + }, + }, nil + } + verifiedStatus := fleet.MDMDeliveryVerified + ds.GetWindowsHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &verifiedStatus, + Type: fleet.CAConfigCustomSCEPProxy, + CAName: "my-custom-ca", + }, nil + } + // ConsumeChallenge should NOT be called for Windows profiles + ds.ConsumeChallengeFunc = func(ctx context.Context, challenge string) error { + return nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "w-profile-uuid", "my-custom-ca", "test-challenge") + scepURL, err := svc.validateIdentifier(ctx, identifier, true) // checkChallenge=true but should be skipped + require.NoError(t, err) + assert.Equal(t, "https://custom-scep.example.com/scep", scepURL) + assert.False(t, ds.ConsumeChallengeFuncInvoked, "ConsumeChallenge should not be called for Windows profiles") + }) + + t.Run("datastore error getting CAs", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return nil, errors.New("database connection failed") + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting grouped certificate authorities") + }) + + t.Run("datastore error getting profile", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return nil, errors.New("database query failed") + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting host MDM profile") + }) + + t.Run("NDES resend error still returns challenge expired error", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{URL: "https://ndes.example.com/scep"}, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + expiredTime := time.Now().Add(-58 * time.Minute) + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigNDES, + CAName: "NDES", + ChallengeRetrievedAt: &expiredTime, + }, nil + } + ds.ResendHostMDMProfileFunc = func(ctx context.Context, hostUUID, profileUUID string) error { + return errors.New("resend failed") + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "NDES", "") + _, err := svc.validateIdentifier(ctx, identifier, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "resending host mdm profile") + }) + + t.Run("default CA name is NDES", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{URL: "https://ndes.example.com/scep"}, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + // Verify default CA name is "NDES" + assert.Equal(t, "NDES", caName) + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigNDES, + CAName: "NDES", + }, nil + } + svc := newTestService(ds) + + // Identifier with only host and profile UUID (no CA name) + identifier := url.PathEscape("host-uuid,a-profile-uuid") + scepURL, err := svc.validateIdentifier(ctx, identifier, false) + require.NoError(t, err) + assert.Equal(t, "https://ndes.example.com/scep", scepURL) + }) + + t.Run("Smallstep CA name mismatch returns not configured", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + Smallstep: []fleet.SmallstepSCEPProxyCA{ + {Name: "other-smallstep", URL: "https://other.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetAppleHostMDMCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID, caName string) (*fleet.HostMDMCertificateProfile, error) { + return &fleet.HostMDMCertificateProfile{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + Status: &pendingStatus, + Type: fleet.CAConfigSmallstep, + CAName: "my-smallstep", // Different from configured + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "a-profile-uuid", "my-smallstep", "") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), MessageSCEPProxyNotConfigured) + }) + + t.Run("Android request", func(t *testing.T) { + ds := new(mock.DataStore) + svc := newTestService(ds) + + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "android-ca", URL: "https://scep.example.com/scep"}, + }, + }, nil + } + + pendingStatus := fleet.MDMDeliveryPending + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + assert.Equal(t, "host-uuid", hostUUID) + assert.Equal(t, uint(1), certificateTemplateID) + return &fleet.CertificateTemplateForHost{ + HostUUID: "host-uuid", + CertificateTemplateID: 1, + FleetChallenge: ptr.String("test-challenge"), + Status: &pendingStatus, + CAType: fleet.CAConfigCustomSCEPProxy, + CAName: "android-ca", + }, nil + } + + // Android identifier format: {hostUUID},g{certificateTemplateID},{caType},{challenge} + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "test-challenge") + scepURL, err := svc.validateIdentifier(ctx, identifier, false) + require.NoError(t, err) + assert.Equal(t, "https://scep.example.com/scep", scepURL) + assert.True(t, ds.GetCertificateTemplateForHostFuncInvoked) + ds.GetCertificateTemplateForHostFuncInvoked = false + }) + + t.Run("Android invalid certificate template ID", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + svc := newTestService(ds) + + // Invalid certificate template ID (not a number) + identifier := makeIdentifier("host-uuid", "ginvalid", "custom_scep_proxy", "test-challenge") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Android certificate template ID") + }) + + t.Run("Android certificate template not found", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return nil, sql.ErrNoRows // Not found + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "test-challenge") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting Android certificate template") + }) + + t.Run("Android status not pending", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "android-ca", URL: "https://scep.example.com/scep"}, + }, + }, nil + } + verifiedStatus := fleet.MDMDeliveryVerified + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return &fleet.CertificateTemplateForHost{ + HostUUID: "host-uuid", + CertificateTemplateID: 1, + FleetChallenge: ptr.String("test-challenge"), + Status: &verifiedStatus, + CAType: fleet.CAConfigCustomSCEPProxy, + CAName: "android-ca", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "test-challenge") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "profile status (verified) is not 'pending'") + }) + + t.Run("Android CA not configured", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{}, // Empty - no CAs configured + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return &fleet.CertificateTemplateForHost{ + HostUUID: "host-uuid", + CertificateTemplateID: 1, + FleetChallenge: ptr.String("test-challenge"), + Status: &pendingStatus, + CAType: fleet.CAConfigCustomSCEPProxy, + CAName: "android-ca", + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "test-challenge") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), MessageSCEPProxyNotConfigured) + }) + + t.Run("Android CA name mismatch", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "other-ca", URL: "https://other.example.com/scep"}, // Different CA name + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return &fleet.CertificateTemplateForHost{ + HostUUID: "host-uuid", + CertificateTemplateID: 1, + FleetChallenge: ptr.String("test-challenge"), + Status: &pendingStatus, + CAType: fleet.CAConfigCustomSCEPProxy, + CAName: "android-ca", // This CA is not configured + }, nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "test-challenge") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), MessageSCEPProxyNotConfigured) + }) + + t.Run("Android with challenge validation", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "android-ca", URL: "https://scep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return &fleet.CertificateTemplateForHost{ + HostUUID: "host-uuid", + CertificateTemplateID: 1, + FleetChallenge: ptr.String("valid-challenge"), + Status: &pendingStatus, + CAType: fleet.CAConfigCustomSCEPProxy, + CAName: "android-ca", + }, nil + } + ds.ConsumeChallengeFunc = func(ctx context.Context, challenge string) error { + assert.Equal(t, "valid-challenge", challenge) + return nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "valid-challenge") + scepURL, err := svc.validateIdentifier(ctx, identifier, true) // checkChallenge=true + require.NoError(t, err) + assert.Equal(t, "https://scep.example.com/scep", scepURL) + assert.True(t, ds.ConsumeChallengeFuncInvoked) + ds.ConsumeChallengeFuncInvoked = false + }) + + t.Run("Android invalid challenge triggers requeue", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "android-ca", URL: "https://scep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return &fleet.CertificateTemplateForHost{ + HostUUID: "host-uuid", + CertificateTemplateID: 1, + FleetChallenge: ptr.String("valid-challenge"), + Status: &pendingStatus, + CAType: fleet.CAConfigCustomSCEPProxy, + CAName: "android-ca", + }, nil + } + ds.ConsumeChallengeFunc = func(ctx context.Context, challenge string) error { + return sql.ErrNoRows // Challenge not found/expired + } + ds.ResendHostCertificateProfileFunc = func(ctx context.Context, hostUUID, profileUUID string) error { + assert.Equal(t, "host-uuid", hostUUID) + assert.Equal(t, "g1", profileUUID) + return nil + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "invalid-challenge") + _, err := svc.validateIdentifier(ctx, identifier, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "custom scep challenge failed") + assert.True(t, ds.ConsumeChallengeFuncInvoked) + assert.True(t, ds.ResendHostCertificateProfileFuncInvoked) + ds.ConsumeChallengeFuncInvoked = false + ds.ResendHostCertificateProfileFuncInvoked = false + }) + + t.Run("Android datastore error getting templates", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return nil, errors.New("database connection failed") + } + svc := newTestService(ds) + + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "test-challenge") + _, err := svc.validateIdentifier(ctx, identifier, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting Android certificate template") + }) + + t.Run("Android uses challenge from template when not in identifier", func(t *testing.T) { + ds := new(mock.DataStore) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{ + CustomScepProxy: []fleet.CustomSCEPProxyCA{ + {Name: "android-ca", URL: "https://scep.example.com/scep"}, + }, + }, nil + } + pendingStatus := fleet.MDMDeliveryPending + ds.GetCertificateTemplateForHostFunc = func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + return &fleet.CertificateTemplateForHost{ + HostUUID: "host-uuid", + CertificateTemplateID: 1, + FleetChallenge: ptr.String("template-challenge"), // Challenge from template + Status: &pendingStatus, + CAType: fleet.CAConfigCustomSCEPProxy, + CAName: "android-ca", + }, nil + } + ds.ConsumeChallengeFunc = func(ctx context.Context, challenge string) error { + // Should use challenge from template since not provided in identifier + assert.Equal(t, "template-challenge", challenge) + return nil + } + svc := newTestService(ds) + + // No challenge in identifier - should use one from template + identifier := makeIdentifier("host-uuid", "g1", "custom_scep_proxy", "") + scepURL, err := svc.validateIdentifier(ctx, identifier, true) + require.NoError(t, err) + assert.Equal(t, "https://scep.example.com/scep", scepURL) + assert.True(t, ds.ConsumeChallengeFuncInvoked) + ds.ConsumeChallengeFuncInvoked = false + }) +} diff --git a/server/datastore/mysql/certificate_templates_test.go b/server/datastore/mysql/certificate_templates_test.go index bf8805031e..756c29759f 100644 --- a/server/datastore/mysql/certificate_templates_test.go +++ b/server/datastore/mysql/certificate_templates_test.go @@ -28,6 +28,7 @@ func TestCertificates(t *testing.T) { {"BatchDeleteCertificateTemplates", testBatchDeleteCertificateTemplates}, {"GetHostCertificateTemplates", testGetHostCertificateTemplates}, {"GetMDMProfileSummaryFromHostCertificateTemplates", testGetMDMProfileSummaryFromHostCertificateTemplates}, + {"GetCertificateTemplateForHost", testGetCertificateTemplateForHost}, } for _, c := range cases { @@ -1052,3 +1053,131 @@ func testGetMDMProfileSummaryFromHostCertificateTemplates(t *testing.T, ds *Data }) } } + +func testGetCertificateTemplateForHost(t *testing.T, ds *Datastore) { + defer TruncateTables(t, ds) + + ctx := context.Background() + + // Create teams + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 1"}) + require.NoError(t, err) + + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 2"}) + require.NoError(t, err) + + // Create hosts + h1 := test.NewHost(t, ds, "host_1", "127.0.0.1", "1", "1", time.Now()) + h1.TeamID = &team1.ID + err = ds.UpdateHost(ctx, h1) + require.NoError(t, err) + + h2 := test.NewHost(t, ds, "host_2", "127.0.0.2", "2", "2", time.Now()) + h2.TeamID = &team2.ID + err = ds.UpdateHost(ctx, h2) + require.NoError(t, err) + + // Create certificate authority + ca, err := ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{ + Type: string(fleet.CATypeCustomSCEPProxy), + Name: ptr.String("Test SCEP CA"), + URL: ptr.String("http://localhost:8080/scep"), + Challenge: ptr.String("test-challenge"), + }) + require.NoError(t, err) + + // Create certificate templates + ct1, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{ + Name: "Template1", + TeamID: team1.ID, + CertificateAuthorityID: ca.ID, + SubjectName: "CN=Test Subject 1", + }) + require.NoError(t, err) + + ct2, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{ + Name: "Template2", + TeamID: team2.ID, + CertificateAuthorityID: ca.ID, + SubjectName: "CN=Test Subject 2", + }) + require.NoError(t, err) + + // Create host_certificate_template record for h1 with ct1 + err = ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{ + { + HostUUID: h1.UUID, + CertificateTemplateID: ct1.ID, + FleetChallenge: "challenge-123", + Status: fleet.MDMDeliveryPending, + }, + }) + require.NoError(t, err) + + testCases := []struct { + name string + do func(*testing.T, *Datastore) + }{ + { + "Returns certificate template for host with host_certificate_template record", + func(t *testing.T, ds *Datastore) { + result, err := ds.GetCertificateTemplateForHost(ctx, h1.UUID, ct1.ID) + require.NoError(t, err) + require.NotNil(t, result) + + require.Equal(t, h1.UUID, result.HostUUID) + require.Equal(t, ct1.ID, result.CertificateTemplateID) + require.NotNil(t, result.FleetChallenge) + require.Equal(t, "challenge-123", *result.FleetChallenge) + require.NotNil(t, result.Status) + require.Equal(t, fleet.MDMDeliveryPending, *result.Status) + require.Equal(t, fleet.CAConfigAssetType(fleet.CATypeCustomSCEPProxy), result.CAType) + require.Equal(t, "Test SCEP CA", result.CAName) + }, + }, + { + "Returns certificate template for host without host_certificate_template record", + func(t *testing.T, ds *Datastore) { + // h2 is in team2 which has ct2, but no host_certificate_template record exists + result, err := ds.GetCertificateTemplateForHost(ctx, h2.UUID, ct2.ID) + require.NoError(t, err) + require.NotNil(t, result) + + require.Equal(t, h2.UUID, result.HostUUID) + require.Equal(t, ct2.ID, result.CertificateTemplateID) + require.Nil(t, result.FleetChallenge) + require.Nil(t, result.Status) + require.Equal(t, fleet.CAConfigAssetType(fleet.CATypeCustomSCEPProxy), result.CAType) + require.Equal(t, "Test SCEP CA", result.CAName) + }, + }, + { + "Returns error when certificate template doesn't belong to host's team", + func(t *testing.T, ds *Datastore) { + // h1 is in team1, ct2 is in team2 + _, err := ds.GetCertificateTemplateForHost(ctx, h1.UUID, ct2.ID) + require.Error(t, err) + }, + }, + { + "Returns error for non-existent host", + func(t *testing.T, ds *Datastore) { + _, err := ds.GetCertificateTemplateForHost(ctx, "non-existent-uuid", ct1.ID) + require.Error(t, err) + }, + }, + { + "Returns error for non-existent certificate template", + func(t *testing.T, ds *Datastore) { + _, err := ds.GetCertificateTemplateForHost(ctx, h1.UUID, 99999) + require.Error(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.do(t, ds) + }) + } +} diff --git a/server/datastore/mysql/host_certificate_templates.go b/server/datastore/mysql/host_certificate_templates.go index 0aca1ad510..3ce1fa84a7 100644 --- a/server/datastore/mysql/host_certificate_templates.go +++ b/server/datastore/mysql/host_certificate_templates.go @@ -70,6 +70,34 @@ func (ds *Datastore) ListCertificateTemplatesForHosts(ctx context.Context, hostU return results, nil } +// GetCertificateTemplateForHost returns a certificate template for a specific host and certificate template ID +func (ds *Datastore) GetCertificateTemplateForHost(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + const stmt = ` + SELECT + hosts.uuid AS host_uuid, + certificate_templates.id AS certificate_template_id, + host_certificate_templates.fleet_challenge AS fleet_challenge, + host_certificate_templates.status AS status, + certificate_authorities.type AS ca_type, + certificate_authorities.name AS ca_name + FROM certificate_templates + INNER JOIN hosts ON hosts.team_id = certificate_templates.team_id + INNER JOIN certificate_authorities ON certificate_authorities.id = certificate_templates.certificate_authority_id + LEFT JOIN host_certificate_templates + ON host_certificate_templates.host_uuid = hosts.uuid + AND host_certificate_templates.certificate_template_id = certificate_templates.id + WHERE + hosts.uuid = ? AND certificate_templates.id = ? + ` + + var result fleet.CertificateTemplateForHost + if err := sqlx.GetContext(ctx, ds.reader(ctx), &result, stmt, hostUUID, certificateTemplateID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get certificate template for host") + } + + return &result, nil +} + // BulkInsertHostCertificateTemplates inserts multiple host_certificate_templates records func (ds *Datastore) BulkInsertHostCertificateTemplates(ctx context.Context, hostCertTemplates []fleet.HostCertificateTemplate) error { if len(hostCertTemplates) == 0 { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index d7ca2f4ee4..ad56511807 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2531,6 +2531,8 @@ type Datastore interface { ListAndroidHostUUIDsWithDeliverableCertificateTemplates(ctx context.Context, offset int, limit int) ([]string, error) // ListCertificateTemplatesForHosts returns ALL certificate templates for the given host UUIDs. ListCertificateTemplatesForHosts(ctx context.Context, hostUUIDs []string) ([]CertificateTemplateForHost, error) + // GetCertificateTemplateForHost returns a certificate template for the given host UUID and certificate template ID. + GetCertificateTemplateForHost(ctx context.Context, hostUUID string, certificateTemplateID uint) (*CertificateTemplateForHost, error) // BulkInsertHostCertificateTemplates inserts multiple host_certificate_templates records. BulkInsertHostCertificateTemplates(ctx context.Context, hostCertTemplates []HostCertificateTemplate) error diff --git a/server/fleet/host_certificate_template.go b/server/fleet/host_certificate_template.go index 612950b9d9..311abea0a8 100644 --- a/server/fleet/host_certificate_template.go +++ b/server/fleet/host_certificate_template.go @@ -35,4 +35,6 @@ type CertificateTemplateForHost struct { CertificateTemplateID uint `db:"certificate_template_id"` FleetChallenge *string `db:"fleet_challenge"` Status *MDMDeliveryStatus `db:"status"` + CAType CAConfigAssetType `db:"ca_type"` + CAName string `db:"ca_name"` } diff --git a/server/mdm/scep/SCEP.md b/server/mdm/scep/SCEP.md new file mode 100644 index 0000000000..88495315d2 --- /dev/null +++ b/server/mdm/scep/SCEP.md @@ -0,0 +1,137 @@ +# Overview + +Fleet implements a SCEP proxy that sits between devices and Certificate Authorities. For Android, only Custom SCEP Proxy is supported. The proxy validates requests and forwards them to the CA. + +## Two Challenge Types + +Custom SCEP uses two different challenges: + +1. **Fleet Challenge** (one-time use) + - Generated by Fleet and stored in the database + - Embedded in the SCEP proxy URL identifier + - Validated by Fleet via `ConsumeChallenge()` during PKIOperation + - Prevents replay attacks and ensures request authenticity + - Consumed (deleted) after successful validation + +2. **Static Challenge** (from CA configuration) + - Configured in the CA settings (`certificate_authorities.challenge_encrypted`) + - Substituted into profile via `$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_` + - Embedded by the device in its encrypted CSR + - Validated by the SCEP CA itself + - Same challenge used across all requests to that CA + +## Architecture Components + +1. Handler Registration (server/service/handler.go) + + `RegisterSCEPProxy(...)` sets up two HTTP endpoints: + - GET /mdm/scep/proxy/{identifier} - For GetCACaps and GetCACert operations + - POST /mdm/scep/proxy/{identifier} - For PKIOperation (certificate signing) + + The {identifier} is a comma-separated string: `hostUUID,profileUUID,caName,fleetChallenge` + +2. SCEP Operations (ee/server/service/scep_proxy.go) + + - **GetCACaps**: Returns CA capabilities (SHA-256, AES, etc.). Pass-through to upstream. + - **GetCACert**: Returns CA certificate(s) in PKCS#7 format. Pass-through to upstream. + - **PKIOperation**: Certificate signing request. This is where Fleet validates the Fleet challenge before forwarding. + +## Flow Diagram (Custom SCEP for Android) + +```mermaid +sequenceDiagram + participant Device + participant Fleet as Fleet SCEP Proxy + participant DB as Fleet Database + participant CA as Custom SCEP CA + + Note over Device,CA: Step 1: Certificate Template Delivery + Fleet->>DB: NewChallenge() - generate Fleet challenge + DB-->>Fleet: Fleet challenge token (one-time use) + Fleet->>DB: Get Static challenge from CA config + DB-->>Fleet: Static challenge (from certificate_authorities table) + Fleet->>Device: Certificate template with:
• SCEP URL containing Fleet challenge
• Static challenge in profile payload + + Note over Device,CA: Step 2: Device Makes SCEP Requests + Device->>Fleet: GET /mdm/scep/proxy/{identifier}?operation=GetCACaps
(identifier contains Fleet challenge) + Fleet->>Fleet: Validate identifier (host, profile exist) + Fleet->>CA: Forward GetCACaps + CA-->>Fleet: CA capabilities + Fleet-->>Device: CA capabilities (SHA-256, AES, etc.) + + Device->>Fleet: GET /mdm/scep/proxy/{identifier}?operation=GetCACert + Fleet->>CA: Forward GetCACert + CA-->>Fleet: CA certificate (PKCS#7) + Fleet-->>Device: CA certificate + + Note over Device,CA: Step 3: Certificate Signing + Device->>Fleet: POST /mdm/scep/proxy/{identifier}?operation=PKIOperation
(encrypted CSR containing Static challenge) + Fleet->>DB: ConsumeChallenge(fleetChallenge)
Validates & deletes Fleet challenge + alt Fleet challenge valid + Fleet->>CA: Forward CSR (CA validates Static challenge) + CA-->>Fleet: Signed certificate + Fleet->>DB: Update status to "verifying" + Fleet-->>Device: Signed certificate + else Fleet challenge invalid/consumed + Note over Fleet,DB: Profile Requeue Flow + Fleet->>DB: ResendHostCertificateProfile()
Sets status = NULL + Fleet-->>Device: Error: "custom scep challenge failed" + Note over Fleet,DB: On next cron run + DB-->>Fleet: Find profiles with status = NULL + Fleet->>DB: NewChallenge() - generate fresh Fleet challenge + Fleet->>Device: Re-send template with new SCEP URL + end +``` + +## How a Device Enrolls (Step-by-step) + +### Step 1: Certificate Template Delivery + +When Fleet sends a certificate template to a device: + +```go +// Generate one-time Fleet challenge +fleetChallenge, err := ds.NewChallenge(ctx) + +// Build SCEP proxy URL with Fleet challenge embedded +proxyURL := fmt.Sprintf("%s/mdm/scep/proxy/%s", + appConfig.MDMUrl(), + url.PathEscape(fmt.Sprintf("%s,%s,%s,%s", hostUUID, templateID, caName, fleetChallenge))) + +// Static challenge is substituted via $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_ +``` + +The template contains: +- SCEP URL: `https://fleet.example.com/mdm/scep/proxy/abc123,tmpl-456,MyCA,xyz789` +- Challenge field: The static challenge from CA configuration + +### Step 2: Device Makes SCEP Requests + +The device makes 3 requests to the proxy URL: + +1. **GET ?operation=GetCACaps** + - Fleet validates the identifier (checks host/profile exist) + - Proxies to upstream CA + - Returns CA capabilities + +2. **GET ?operation=GetCACert** + - Fleet proxies to CA + - Returns CA certificate + +3. **POST ?operation=PKIOperation** (with encrypted CSR in body) + - Fleet validates and consumes the Fleet challenge via `ConsumeChallenge()` + - If valid, forwards CSR to CA (CA validates the static challenge inside the CSR) + - Returns signed certificate + +### Step 3: Challenge Validation + +For Custom SCEP: +- Fleet challenge is one-time use, validated via `ConsumeChallenge(ctx, fleetChallenge)` +- If the Fleet challenge was already used or doesn't exist, the request fails +- Fleet calls `ResendHostCertificateProfile()` to requeue the profile with a fresh Fleet challenge + +### Step 4: Status Tracking + +- Profile must be in "pending" state to proceed +- After successful certificate issuance, status updates to "verifying" +- If Fleet challenge validation fails, profile is requeued (status set to NULL) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index e11095b1e9..048b1cc0b5 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1655,6 +1655,8 @@ type ListAndroidHostUUIDsWithDeliverableCertificateTemplatesFunc func(ctx contex type ListCertificateTemplatesForHostsFunc func(ctx context.Context, hostUUIDs []string) ([]fleet.CertificateTemplateForHost, error) +type GetCertificateTemplateForHostFunc func(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) + type BulkInsertHostCertificateTemplatesFunc func(ctx context.Context, hostCertTemplates []fleet.HostCertificateTemplate) error type GetCurrentTimeFunc func(ctx context.Context) (time.Time, error) @@ -4110,6 +4112,9 @@ type DataStore struct { ListCertificateTemplatesForHostsFunc ListCertificateTemplatesForHostsFunc ListCertificateTemplatesForHostsFuncInvoked bool + GetCertificateTemplateForHostFunc GetCertificateTemplateForHostFunc + GetCertificateTemplateForHostFuncInvoked bool + BulkInsertHostCertificateTemplatesFunc BulkInsertHostCertificateTemplatesFunc BulkInsertHostCertificateTemplatesFuncInvoked bool @@ -9834,6 +9839,13 @@ func (s *DataStore) ListCertificateTemplatesForHosts(ctx context.Context, hostUU return s.ListCertificateTemplatesForHostsFunc(ctx, hostUUIDs) } +func (s *DataStore) GetCertificateTemplateForHost(ctx context.Context, hostUUID string, certificateTemplateID uint) (*fleet.CertificateTemplateForHost, error) { + s.mu.Lock() + s.GetCertificateTemplateForHostFuncInvoked = true + s.mu.Unlock() + return s.GetCertificateTemplateForHostFunc(ctx, hostUUID, certificateTemplateID) +} + func (s *DataStore) BulkInsertHostCertificateTemplates(ctx context.Context, hostCertTemplates []fleet.HostCertificateTemplate) error { s.mu.Lock() s.BulkInsertHostCertificateTemplatesFuncInvoked = true diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ac585b794b..eb07d52ad5 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -16240,7 +16240,7 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() { // Invalid profile identifier scepRes := s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+"invalid_identifier,p1234-uuid", nil, http.StatusBadRequest, nil, "operation", "GetCACaps") scepResErr := extractServerErrorText(scepRes.Body) - require.Contains(t, scepResErr, "invalid profile UUID (only Apple and Windows") + require.Contains(t, scepResErr, "invalid profile UUID (only Apple, Windows, and Android") // Verify Windows profiles is allowed (with dummy values that will fail lookup) scepRes = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+"invalid_identifier,w1234-uuid", nil, http.StatusBadRequest, nil, "operation", "GetCACaps")