diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CertificateValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CertificateValidatorTests.cs
new file mode 100644
index 00000000..38a77b5d
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/CertificateValidatorTests.cs
@@ -0,0 +1,127 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using AutopilotMonitor.Functions.Security;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for CertificateValidator chain-trust enforcement.
+///
+/// The validator pins trust to embedded Intune root certs via X509ChainTrustMode.CustomRootTrust.
+/// A self-signed leaf cert whose Subject contains "Microsoft Intune" must NOT validate -
+/// guards against the prior bypass where AllowUnknownCertificateAuthority + DN-substring
+/// matching let any forged self-signed cert pass.
+///
+public class CertificateValidatorTests
+{
+ [Fact]
+ public void ValidateCertificate_WithEmptyBase64_ReturnsInvalid()
+ {
+ var result = CertificateValidator.ValidateCertificate("");
+
+ Assert.False(result.IsValid);
+ Assert.Contains("No certificate", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ValidateCertificate_WithNull_ReturnsInvalid()
+ {
+ var result = CertificateValidator.ValidateCertificate(null);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public void ValidateCertificate_WithMalformedBase64_ReturnsInvalid()
+ {
+ var result = CertificateValidator.ValidateCertificate("not-base64!!!");
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public void ValidateCertificate_WithSelfSignedCertImpersonatingIntune_ReturnsInvalid()
+ {
+ // Regression test: a self-signed leaf with Subject CN="Microsoft Intune MDM Device CA"
+ // and Client-Auth EKU previously passed validation under
+ // AllowUnknownCertificateAuthority + DN substring match. Under CustomRootTrust the
+ // chain has no signature link to a pinned Intune root and must be rejected.
+ var b64 = CreateSelfSignedCertBase64(
+ subjectCn: "Microsoft Intune MDM Device CA",
+ includeClientAuthEku: true);
+
+ var result = CertificateValidator.ValidateCertificate(b64);
+
+ Assert.False(result.IsValid);
+ Assert.Contains("chain validation failed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ValidateCertificate_WithSelfSignedCertImpersonatingMdmDeviceCa_ReturnsInvalid()
+ {
+ // Second variant of the Subject-substring bypass.
+ var b64 = CreateSelfSignedCertBase64(
+ subjectCn: "MDM Device CA",
+ includeClientAuthEku: true);
+
+ var result = CertificateValidator.ValidateCertificate(b64);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public void ValidateCertificate_WithSelfSignedNonIntuneCert_ReturnsInvalid()
+ {
+ var b64 = CreateSelfSignedCertBase64(
+ subjectCn: "test-device.contoso.example",
+ includeClientAuthEku: true);
+
+ var result = CertificateValidator.ValidateCertificate(b64);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public void ValidateCertificate_WithExpiredSelfSignedCert_ReturnsInvalid()
+ {
+ var b64 = CreateSelfSignedCertBase64(
+ subjectCn: "Microsoft Intune MDM Device CA",
+ includeClientAuthEku: true,
+ notBefore: DateTimeOffset.UtcNow.AddYears(-2),
+ notAfter: DateTimeOffset.UtcNow.AddYears(-1));
+
+ var result = CertificateValidator.ValidateCertificate(b64);
+
+ Assert.False(result.IsValid);
+ Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string CreateSelfSignedCertBase64(
+ string subjectCn,
+ bool includeClientAuthEku,
+ DateTimeOffset? notBefore = null,
+ DateTimeOffset? notAfter = null)
+ {
+ using var rsa = RSA.Create(2048);
+ var req = new CertificateRequest(
+ $"CN={subjectCn}",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ if (includeClientAuthEku)
+ {
+ // 1.3.6.1.5.5.7.3.2 = Client Authentication
+ req.CertificateExtensions.Add(
+ new X509EnhancedKeyUsageExtension(
+ new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") },
+ critical: false));
+ }
+
+ var nb = notBefore ?? DateTimeOffset.UtcNow.AddDays(-1);
+ var na = notAfter ?? DateTimeOffset.UtcNow.AddYears(1);
+ using var cert = req.CreateSelfSigned(nb, na);
+
+ return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs
index ebab0cac..9a448ba9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs
@@ -15,9 +15,9 @@ namespace AutopilotMonitor.Functions.Security
///
///
/// Security measures:
- /// - Full X.509 chain validation using X509Chain.Build()
+ /// - X.509 chain trust pinned to embedded Intune root certificate(s) via
+ /// X509ChainTrustMode.CustomRootTrust (OS trust store is ignored)
/// - No revocation checking (Intune certificates have no CRL/OCSP endpoints)
- /// - Validates entire chain contains Microsoft Intune CA
/// - Prevents self-signed certificate attacks
/// - Validates Enhanced Key Usage for Client Authentication
/// - In-memory cache for validated certificates (5 minute TTL)
@@ -29,8 +29,13 @@ namespace AutopilotMonitor.Functions.Security
private static readonly ConcurrentDictionary _validationCache = new();
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
- // Lazily loaded Intune CA certs from embedded resources
- private static readonly Lazy _intuneCaCerts = new(LoadIntuneCaCertificates);
+ // Lazily loaded Intune CA certs from embedded PEM resources, split by role:
+ // roots -> pinned trust anchors (chain.ChainPolicy.CustomTrustStore)
+ // intermediates -> bridge candidates (chain.ChainPolicy.ExtraStore)
+ private static readonly Lazy _intuneRootCerts =
+ new(() => LoadIntunePemBundle("intune-root-ca.pem"));
+ private static readonly Lazy _intuneIntermediateCerts =
+ new(() => LoadIntunePemBundle("intune-intermediate-ca.pem"));
private class CachedValidationResult
{
@@ -101,31 +106,50 @@ namespace AutopilotMonitor.Functions.Security
};
}
- // 2. Validate certificate chain (without revocation check - Intune certs have no CRL/OCSP)
+ // 2. Validate certificate chain against pinned Intune roots (no revocation - no CRL/OCSP).
//
- // On Linux (Azure Functions flex consumption plan) the OS trust store does not contain
- // Microsoft Intune CAs. We load them from embedded PEM resources into ExtraStore and set
- // AllowUnknownCertificateAuthority so chain.Build() can resolve the issuer without the OS
- // store. The issuer identity is still enforced in step 3.
+ // CustomTrustStore + CustomRootTrust pins trust to the embedded Intune root cert(s)
+ // only and ignores the OS trust store entirely - works on Linux/Flex Consumption
+ // (no machine store) and replaces the previous AllowUnknownCertificateAuthority
+ // workaround, which was bypassable by self-signed leaf certs whose Subject contained
+ // "Microsoft Intune".
+ var roots = _intuneRootCerts.Value;
+ var intermediates = _intuneIntermediateCerts.Value;
+
+ if (roots.Length == 0)
+ {
+ logger?.LogError("No embedded Intune root certificates loaded - failing closed");
+ return new CertificateValidationResult
+ {
+ IsValid = false,
+ ErrorMessage = "Server misconfiguration: no Intune trust anchors available",
+ Thumbprint = thumbprint
+ };
+ }
+
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
- chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
+ chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10);
+ chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
- // Load embedded Intune CA certs into ExtraStore
- var caCerts = _intuneCaCerts.Value;
- logger?.LogDebug("Loading {Count} embedded Intune CA certificate(s) into chain ExtraStore", caCerts.Length);
- foreach (var caCert in caCerts)
- chain.ChainPolicy.ExtraStore.Add(caCert);
+ foreach (var root in roots)
+ chain.ChainPolicy.CustomTrustStore.Add(root);
+ foreach (var intermediate in intermediates)
+ chain.ChainPolicy.ExtraStore.Add(intermediate);
+
+ logger?.LogDebug(
+ "Chain build with {RootCount} pinned Intune root(s) + {IntermediateCount} intermediate(s)",
+ roots.Length, intermediates.Length);
var chainValid = chain.Build(certificate);
if (!chainValid)
{
var chainErrors = string.Join(", ", chain.ChainStatus.Select(s => s.StatusInformation));
- logger?.LogWarning($"Certificate chain validation failed: {chainErrors}");
+ logger?.LogWarning("Certificate chain validation failed: {ChainErrors}", chainErrors);
return new CertificateValidationResult
{
@@ -135,29 +159,12 @@ namespace AutopilotMonitor.Functions.Security
};
}
- // 3. Verify issuer chain contains Microsoft Intune CA
- var hasValidIssuer = chain.ChainElements
- .Cast()
- .Any(element =>
- element.Certificate.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
- element.Certificate.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) ||
- element.Certificate.Subject.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
- element.Certificate.Subject.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase));
+ // chainValid == true under CustomRootTrust proves the leaf is transitively signed
+ // by one of the pinned Intune roots. The previous Subject/Issuer DN substring check
+ // is intentionally removed - it added no security on top of root pinning and would
+ // false-negative if Microsoft renames the CA.
- if (!hasValidIssuer)
- {
- var chainInfo = string.Join(" -> ", chain.ChainElements.Cast().Select(e => e.Certificate.Subject));
- logger?.LogWarning($"Certificate chain does not contain Microsoft Intune CA: {chainInfo}");
-
- return new CertificateValidationResult
- {
- IsValid = false,
- ErrorMessage = $"Certificate not issued by Microsoft Intune CA",
- Thumbprint = thumbprint
- };
- }
-
- // 4. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
+ // 3. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
var hasClientAuth = certificate.Extensions
.OfType()
.Any(ext => ext.EnhancedKeyUsages
@@ -207,48 +214,40 @@ namespace AutopilotMonitor.Functions.Security
}
///
- /// Loads Intune CA certificates from embedded PEM resources.
- /// Files: Security/Certificates/intune-intermediate-ca.pem
- /// Security/Certificates/intune-root-ca.pem
+ /// Loads one or more X.509 certs from a PEM-bundle embedded resource under
+ /// Security/Certificates/. Roles are filename-segregated:
+ /// intune-root-ca.pem -> pinned trust anchors (CustomTrustStore)
+ /// intune-intermediate-ca.pem -> chain bridge candidates (ExtraStore)
///
- private static X509Certificate2[] LoadIntuneCaCertificates()
+ private static X509Certificate2[] LoadIntunePemBundle(string fileName)
{
+ var resourceName = $"AutopilotMonitor.Functions.Security.Certificates.{fileName}";
var assembly = Assembly.GetExecutingAssembly();
- var resourceNames = new[]
+
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ return [];
+
+ using var reader = new StreamReader(stream);
+ var pem = reader.ReadToEnd();
+
+ if (pem.Contains("PLACEHOLDER_REPLACE_WITH_BASE64"))
+ return [];
+
+ // PEM may contain multiple cert blocks (e.g. cross-signed roots); parse each.
+ const string begin = "-----BEGIN CERTIFICATE-----";
+ const string end = "-----END CERTIFICATE-----";
+ var certs = new List();
+ int idx = 0;
+ while ((idx = pem.IndexOf(begin, idx, StringComparison.Ordinal)) >= 0)
{
- "AutopilotMonitor.Functions.Security.Certificates.intune-intermediate-ca.pem",
- "AutopilotMonitor.Functions.Security.Certificates.intune-root-ca.pem"
- };
-
- List certs = [];
-
- foreach (var resourceName in resourceNames)
- {
- using var stream = assembly.GetManifestResourceStream(resourceName);
- if (stream == null)
- {
- // Resource not found - not fatal, chain validation will fall back to OS store
- continue;
- }
-
- using var reader = new StreamReader(stream);
- var pem = reader.ReadToEnd();
-
- // Skip placeholder files that haven't been filled with a real cert yet
- if (pem.Contains("PLACEHOLDER_REPLACE_WITH_BASE64"))
- continue;
-
- try
- {
- var cert = X509Certificate2.CreateFromPem(pem);
- certs.Add(cert);
- }
- catch
- {
- // Malformed PEM - skip silently, chain validation continues with OS store
- }
+ var endIdx = pem.IndexOf(end, idx, StringComparison.Ordinal);
+ if (endIdx < 0) break;
+ var block = pem.Substring(idx, endIdx + end.Length - idx);
+ try { certs.Add(X509Certificate2.CreateFromPem(block)); }
+ catch { /* malformed block - skip; chain.Build() fails closed if no roots remain */ }
+ idx = endIdx + end.Length;
}
-
return certs.ToArray();
}
}