Android custom SCEP backend validation (#36318)

This commit is contained in:
Tim Lee
2025-12-02 14:05:08 -07:00
committed by GitHub
parent 83c8229a76
commit b00d1e4c9c
9 changed files with 1377 additions and 24 deletions
+137 -23
View File
@@ -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
}
+929
View File
@@ -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
})
}
@@ -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)
})
}
}
@@ -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 {
+2
View File
@@ -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
@@ -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"`
}
+137
View File
@@ -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_<CA_NAME>`
- 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:<br/>• SCEP URL containing Fleet challenge<br/>• Static challenge in profile payload
Note over Device,CA: Step 2: Device Makes SCEP Requests
Device->>Fleet: GET /mdm/scep/proxy/{identifier}?operation=GetCACaps<br/>(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<br/>(encrypted CSR containing Static challenge)
Fleet->>DB: ConsumeChallenge(fleetChallenge)<br/>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()<br/>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_<CA_NAME>
```
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)
+12
View File
@@ -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
+1 -1
View File
@@ -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")