diff --git a/changes/47492-windows-scep-challenge-failed-profile b/changes/47492-windows-scep-challenge-failed-profile
new file mode 100644
index 0000000000..037eb6fbfd
--- /dev/null
+++ b/changes/47492-windows-scep-challenge-failed-profile
@@ -0,0 +1 @@
+- Windows SCEP profiles now fail with a clear message when the certificate authority challenge contains characters Windows doesn't support (ASN.1 PrintableString), instead of showing "Verified" while no certificate is installed.
diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx
index cffd1c3b84..2ff04f5fc1 100644
--- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx
+++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx
@@ -25,6 +25,21 @@ describe("generateErrorTooltip", () => {
expect(result).toBeNull();
});
+ it("renders a windows certificate install error as is, without key-value formatting", () => {
+ const detail = `Couldn't install certificate. The "WINSCEPTEST" certificate authority challenge includes characters Windows doesn't support. Allowed: letters, numbers, spaces, and ' ( ) + , - . / : = ?`;
+ const tooltip = generateErrorTooltip(
+ createMockHostMdmProfile({
+ platform: "windows",
+ status: "failed",
+ detail,
+ })
+ );
+
+ renderTooltip(tooltip);
+
+ expect(screen.getByText(detail)).toBeInTheDocument();
+ });
+
it("formats a windows profile error with key-value pairs", () => {
const tooltip = generateErrorTooltip(
createMockHostMdmProfile({
diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx
index 719af5fddd..7d3974cc6e 100644
--- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx
+++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx
@@ -129,11 +129,12 @@ const formatDetailWindowsProfile = (detail: string) => {
const keyValuePairs = detail.split(/, */);
const formattedElements: JSX.Element[] = [];
- // Special case to handle bitlocker error message. It does not follow the
- // expected string format so we will just render the error message as is.
+ // Special case to handle bitlocker and certificate install error messages.
+ // They do not follow the expected string format so we will just render the error message as is.
if (
detail.includes("BitLocker") ||
- detail.includes("preparing volume for encryption")
+ detail.includes("preparing volume for encryption") ||
+ detail.startsWith("Couldn't install certificate")
) {
return detail;
}
diff --git a/server/mdm/microsoft/profile_variables.go b/server/mdm/microsoft/profile_variables.go
index dd74827203..4b6c2438e8 100644
--- a/server/mdm/microsoft/profile_variables.go
+++ b/server/mdm/microsoft/profile_variables.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "regexp"
"slices"
"strings"
"time"
@@ -22,6 +23,16 @@ func PreprocessWindowsProfileContentsForDeployment(deps ProfilePreprocessDepende
return preprocessWindowsProfileContents(deps, params, profileContents)
}
+// windowsSCEPChallengeRegexp matches challenges made up entirely of characters valid in an ASN.1 PrintableString: letters,
+// digits, space, and ' ( ) + , - . / : = ?. Windows encodes the SCEP challenge password as a PrintableString, so a challenge with
+// any other character (most commonly "_") makes enrollment fail on-device with "The string contains a non-printable character."
+// The space is allowed anywhere, including leading and trailing, and was verified to enroll fine on Windows 11.
+var windowsSCEPChallengeRegexp = regexp.MustCompile(`^[A-Za-z0-9 '()+,./:=?-]*$`)
+
+// scepChallengeInvalidCharsDetail is the host profile failure detail shown on the Host details page when a custom SCEP proxy
+// challenge contains characters Windows can't encode as a PrintableString.
+const scepChallengeInvalidCharsDetail = `Couldn't install certificate. The "%s" certificate authority challenge includes characters Windows doesn't support. Allowed: letters, numbers, spaces, and ' ( ) + , - . / : = ?`
+
// MicrosoftProfileProcessingError is used to indicate errors during Microsoft profile processing, such as variable replacement failures.
// It should not break the entire deployment flow, but rather be handled gracefully at the profile level, setting it to failed and detail = Error()
type MicrosoftProfileProcessingError struct {
@@ -135,6 +146,11 @@ func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params
if err != nil {
return profileContents, err
}
+ if ca := deps.CustomSCEPCAs[caName]; ca != nil && !windowsSCEPChallengeRegexp.MatchString(ca.Challenge) {
+ return profileContents, &MicrosoftProfileProcessingError{
+ message: fmt.Sprintf(scepChallengeInvalidCharsDetail, caName),
+ }
+ }
replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(deps.Context, deps.Logger, fleetVar, deps.CustomSCEPCAs, result)
if err != nil {
return profileContents, ctxerr.Wrap(deps.Context, err, "replacing custom SCEP challenge variable")
diff --git a/server/mdm/microsoft/profile_variables_test.go b/server/mdm/microsoft/profile_variables_test.go
index 93cec4c493..6195d6baa2 100644
--- a/server/mdm/microsoft/profile_variables_test.go
+++ b/server/mdm/microsoft/profile_variables_test.go
@@ -268,6 +268,45 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
}
},
},
+ {
+ name: "custom scep proxy challenge with character windows doesn't support",
+ hostUUID: "test-host-1234-uuid",
+ profileContents: `$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE`,
+ expectError: true,
+ processingError: fmt.Sprintf(scepChallengeInvalidCharsDetail, "CERTIFICATE"),
+ setup: func() {
+ ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) {
+ return []*fleet.CertificateAuthority{
+ {
+ ID: 1,
+ Name: new("CERTIFICATE"),
+ Type: string(fleet.CATypeCustomSCEPProxy),
+ URL: new("https://scep.proxy.url/scep"),
+ Challenge: new("super_secret"),
+ },
+ }, nil
+ }
+ },
+ },
+ {
+ name: "custom scep proxy challenge with leading and trailing spaces preserved",
+ hostUUID: "test-host-1234-uuid",
+ profileContents: `$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE`,
+ expectedContents: ` super secret `,
+ setup: func() {
+ ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) {
+ return []*fleet.CertificateAuthority{
+ {
+ ID: 1,
+ Name: new("CERTIFICATE"),
+ Type: string(fleet.CATypeCustomSCEPProxy),
+ URL: new("https://scep.proxy.url/scep"),
+ Challenge: new(" super secret "),
+ },
+ }, nil
+ }
+ },
+ },
{
name: "all idp variables",
hostUUID: "idp-host-uuid",