fix(backend/security): pin client cert chain trust to embedded Intune root

CertificateValidator previously combined AllowUnknownCertificateAuthority
with a DN-substring scan ("Microsoft Intune"/"MDM Device CA" in
Subject/Issuer of any chain element). A self-signed leaf with that DN
satisfied the substring gate while the unknown-root flag masked the
untrusted-root status -> the X.509 trust gate was bypassable.

Switch to X509ChainTrustMode.CustomRootTrust with the embedded Intune
root in CustomTrustStore and the intermediate in ExtraStore. chain.Build()
now requires a signature link to a pinned root regardless of the OS
trust store, which keeps Linux/Flex Consumption working without the
self-signed bypass. Empty roots -> fail-closed.

Adds 7 unit tests including the self-signed-impersonating-Intune
regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-08 14:22:20 +02:00
parent 4870e09b39
commit 7e21591b55
2 changed files with 201 additions and 75 deletions
@@ -0,0 +1,127 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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));
}
}
@@ -15,9 +15,9 @@ namespace AutopilotMonitor.Functions.Security
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Security measures: /// 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) /// - No revocation checking (Intune certificates have no CRL/OCSP endpoints)
/// - Validates entire chain contains Microsoft Intune CA
/// - Prevents self-signed certificate attacks /// - Prevents self-signed certificate attacks
/// - Validates Enhanced Key Usage for Client Authentication /// - Validates Enhanced Key Usage for Client Authentication
/// - In-memory cache for validated certificates (5 minute TTL) /// - In-memory cache for validated certificates (5 minute TTL)
@@ -29,8 +29,13 @@ namespace AutopilotMonitor.Functions.Security
private static readonly ConcurrentDictionary<string, CachedValidationResult> _validationCache = new(); private static readonly ConcurrentDictionary<string, CachedValidationResult> _validationCache = new();
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
// Lazily loaded Intune CA certs from embedded resources // Lazily loaded Intune CA certs from embedded PEM resources, split by role:
private static readonly Lazy<X509Certificate2[]> _intuneCaCerts = new(LoadIntuneCaCertificates); // roots -> pinned trust anchors (chain.ChainPolicy.CustomTrustStore)
// intermediates -> bridge candidates (chain.ChainPolicy.ExtraStore)
private static readonly Lazy<X509Certificate2[]> _intuneRootCerts =
new(() => LoadIntunePemBundle("intune-root-ca.pem"));
private static readonly Lazy<X509Certificate2[]> _intuneIntermediateCerts =
new(() => LoadIntunePemBundle("intune-intermediate-ca.pem"));
private class CachedValidationResult 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 // CustomTrustStore + CustomRootTrust pins trust to the embedded Intune root cert(s)
// Microsoft Intune CAs. We load them from embedded PEM resources into ExtraStore and set // only and ignores the OS trust store entirely - works on Linux/Flex Consumption
// AllowUnknownCertificateAuthority so chain.Build() can resolve the issuer without the OS // (no machine store) and replaces the previous AllowUnknownCertificateAuthority
// store. The issuer identity is still enforced in step 3. // 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(); using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain; chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
chain.ChainPolicy.VerificationTime = DateTime.UtcNow; chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10); chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10);
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
// Load embedded Intune CA certs into ExtraStore foreach (var root in roots)
var caCerts = _intuneCaCerts.Value; chain.ChainPolicy.CustomTrustStore.Add(root);
logger?.LogDebug("Loading {Count} embedded Intune CA certificate(s) into chain ExtraStore", caCerts.Length); foreach (var intermediate in intermediates)
foreach (var caCert in caCerts) chain.ChainPolicy.ExtraStore.Add(intermediate);
chain.ChainPolicy.ExtraStore.Add(caCert);
logger?.LogDebug(
"Chain build with {RootCount} pinned Intune root(s) + {IntermediateCount} intermediate(s)",
roots.Length, intermediates.Length);
var chainValid = chain.Build(certificate); var chainValid = chain.Build(certificate);
if (!chainValid) if (!chainValid)
{ {
var chainErrors = string.Join(", ", chain.ChainStatus.Select(s => s.StatusInformation)); 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 return new CertificateValidationResult
{ {
@@ -135,29 +159,12 @@ namespace AutopilotMonitor.Functions.Security
}; };
} }
// 3. Verify issuer chain contains Microsoft Intune CA // chainValid == true under CustomRootTrust proves the leaf is transitively signed
var hasValidIssuer = chain.ChainElements // by one of the pinned Intune roots. The previous Subject/Issuer DN substring check
.Cast<X509ChainElement>() // is intentionally removed - it added no security on top of root pinning and would
.Any(element => // false-negative if Microsoft renames the CA.
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));
if (!hasValidIssuer) // 3. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
{
var chainInfo = string.Join(" -> ", chain.ChainElements.Cast<X509ChainElement>().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)
var hasClientAuth = certificate.Extensions var hasClientAuth = certificate.Extensions
.OfType<X509EnhancedKeyUsageExtension>() .OfType<X509EnhancedKeyUsageExtension>()
.Any(ext => ext.EnhancedKeyUsages .Any(ext => ext.EnhancedKeyUsages
@@ -207,48 +214,40 @@ namespace AutopilotMonitor.Functions.Security
} }
/// <summary> /// <summary>
/// Loads Intune CA certificates from embedded PEM resources. /// Loads one or more X.509 certs from a PEM-bundle embedded resource under
/// Files: Security/Certificates/intune-intermediate-ca.pem /// Security/Certificates/. Roles are filename-segregated:
/// Security/Certificates/intune-root-ca.pem /// intune-root-ca.pem -> pinned trust anchors (CustomTrustStore)
/// intune-intermediate-ca.pem -> chain bridge candidates (ExtraStore)
/// </summary> /// </summary>
private static X509Certificate2[] LoadIntuneCaCertificates() private static X509Certificate2[] LoadIntunePemBundle(string fileName)
{ {
var resourceName = $"AutopilotMonitor.Functions.Security.Certificates.{fileName}";
var assembly = Assembly.GetExecutingAssembly(); 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<X509Certificate2>();
int idx = 0;
while ((idx = pem.IndexOf(begin, idx, StringComparison.Ordinal)) >= 0)
{ {
"AutopilotMonitor.Functions.Security.Certificates.intune-intermediate-ca.pem", var endIdx = pem.IndexOf(end, idx, StringComparison.Ordinal);
"AutopilotMonitor.Functions.Security.Certificates.intune-root-ca.pem" if (endIdx < 0) break;
}; var block = pem.Substring(idx, endIdx + end.Length - idx);
try { certs.Add(X509Certificate2.CreateFromPem(block)); }
List<X509Certificate2> certs = []; catch { /* malformed block - skip; chain.Build() fails closed if no roots remain */ }
idx = endIdx + end.Length;
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
}
} }
return certs.ToArray(); return certs.ToArray();
} }
} }