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

Re-applies 7e21591b after the chain-validation outage and adds the missing
trust anchors. The first attempt embedded only the 2030 root and the 2026
intermediate; current production device certs chain via the 2026 root +
the 2028 intermediate, so chain.Build() returned "unable to get local
issuer certificate" for every legitimate agent and broke enrolment.

What is different in v2:
- Bundle expanded with both Intune roots (exp 2026-08-12 and 2030-09-15)
  and both currently-issuing MDM Device CA intermediates (exp 2026-05-05
  and 2028-09-15). Real device certs chain via 2028-intermediate -> 2030-
  root today; the 2026 generation is kept for any older device certs
  still in flight.
- Loader now globs intune-root-*.pem and intune-intermediate-*.pem from
  embedded resources and dedupes by thumbprint, so a future Intune root
  rotation is a drop-in PEM with no code change. Legacy generic-named
  files (intune-root-ca.pem, intune-intermediate-ca.pem) are duplicates
  of the new ones and harmlessly de-dup at load.
- Adds a happy-path integration test against a real Intune-issued device
  cert (public-key only, copied from a test VM). This is the regression
  test that would have caught the v1 outage and gates any future bundle
  drift in CI.
- Fixes a pre-existing TZ bug in the expiry check: X509Certificate2's
  NotBefore/NotAfter return Local time, were being compared raw against
  DateTime.UtcNow. Surfaced by the integration test on a CEST dev box;
  unobservable in prod because Linux Flex Consumption runs in UTC.

Trust model is unchanged from v1: X509ChainTrustMode.CustomRootTrust
pinned to the embedded Intune roots, OS trust store ignored, fail-closed
when no roots are loaded. Self-signed-with-Intune-DN bypass remains shut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-08 15:28:21 +02:00
parent 6658303c0d
commit 9af3de15d4
9 changed files with 397 additions and 66 deletions
@@ -26,4 +26,10 @@
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" /> <ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="device-cert-sample.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,155 @@
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.
/// Two layers of coverage:
/// - Happy path: a real Intune-issued device cert (public-key only, copied from a test VM)
/// must validate successfully. This guards against rollouts where the embedded root/intermediate
/// bundle drifts from the chain Microsoft is actually issuing.
/// - Negative path: self-signed leaf certs (with or without Intune-shaped DNs) must NOT validate.
/// </summary>
public class CertificateValidatorTests
{
private const string DeviceSamplePem = "device-cert-sample.pem";
[Fact]
public void ValidateCertificate_WithRealIntuneDeviceCert_ReturnsValid()
{
// Real device cert (public-key only) issued by "Microsoft Intune MDM Device CA".
// This is the regression test that would have caught the previous outage where the
// embedded root bundle didn't match the chain Microsoft uses for current devices.
var pemPath = ResolveSamplePath();
Assert.True(File.Exists(pemPath), $"Sample cert not found at {pemPath}");
var pem = File.ReadAllText(pemPath);
using var cert = X509Certificate2.CreateFromPem(pem);
var b64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
var result = CertificateValidator.ValidateCertificate(b64);
Assert.True(result.IsValid, $"Real device cert rejected: {result.ErrorMessage}");
Assert.False(string.IsNullOrEmpty(result.Thumbprint));
}
[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()
{
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 ResolveSamplePath()
{
var assemblyDir = Path.GetDirectoryName(typeof(CertificateValidatorTests).Assembly.Location)!;
return Path.Combine(assemblyDir, DeviceSamplePem);
}
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));
}
}
@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEeTCCA2GgAwIBAgIQ3CvOdsqKrJFKrCZd9mi7RjANBgkqhkiG9w0BAQsFADAp
MScwJQYDVQQDEx5NaWNyb3NvZnQgSW50dW5lIE1ETSBEZXZpY2UgQ0EwHhcNMjYw
NTA4MTIxNjQ5WhcNMjcwNTA4MDAxNjIyWjAvMS0wKwYDVQQDEyQwN2JjMDE2Ny05
MDYxLTQzYjctYjI0My0wYmI2YWVkYTczNmUwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDQR7R0I80KYq0j/ix1RxPmCgiHFr3a6cUVfcMtJnrGYQUg/Ngh
PGigNyV/CxQ2qfExAjXh1OJ112NYo+Hhrwgl901r42s1bvU8JCXiXWERG87pnCv8
LQ4sRLAW91bz3phhKT5I8gk4uPCJZqTjpZ+/cpdU3CrK5bS8lb23M+s5IizcNAtR
DCTOBXc8My9Yy5ExZZ41uo+xOcP+RQ4yp5bxTzzT2gx5BVsc1QA9f4mNzdMQ8dyC
ejygU+UwiCJsVTKIFtqhLIrgi5Bszuq2611wma2nKjfRUlJQITGCGCccCegKTQNO
eaiYUoS/Hj9lh+gAej6OmCJCoomKWtB6lgUTAgMBAAGjggGVMIIBkTAdBgNVHQ4E
FgQU762JgixoNCU2IZEnzm7sMy3ZSTAwHwYDVR0jBBgwFoAUjrJY+zZQjryKKzg8
w0YEkXJJJRowHgYIKoZIhvcUBQsEEgQQarzWQl5UmUGVKayL/NSeijAeBggqhkiG
9xQFDgQSBBCvwU21IFNgT7XUgh4M8qNZMA8GCCqGSIb3FAUPBAMCAQIwDwYIKoZI
hvcUBRAEAwIBDTAPBggqhkiG9xQFEQQDAgEOMA8GCCqGSIb3FAUSBAMCAQEwDwYI
KoZIhvcUBRMEAwIBADAWBggqhkiG9xQFFwQKAghAAAAAAAABEDAuBggqhkiG9xQF
GAQiBCCVN3VWIg9eIC3z9Bjd96ocQSWOMgtwdKFx2nalSqHDvzAcBggqhkiG9xQF
BAQQZwG8B2GQt0OyQwu2rtpzbjAeBggqhkiG9xQFBgQSBBA9hd7edOJrQJ0NRrb+
ZbRAMA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoG
CCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4IBAQBzgrcC8ky3A/U5iWPJApoH9T99
izrA1SZQ6KNBpSfZPCeRAOtL+H8kxnFf5kIX//9qp00TcBlXAAIw9kXwSeCCWrfx
76eFDJrbffyG11TN8L/LOwBaaFAnkOD+yKe/gvMYUyEdQFhDF9pPRr2uUV5L7RYe
jZ8RojKl8oid6dlp4ZcY7k25WaZOI/UR03+I4PKl8/m8fkKWPcHY07uPxsdwWyIe
Y1VQMborLHe8df2ZxS0wEfAtfjdahk8qLV6uw2VzJI0/S5HZDCAc9agGEKUh8KGC
xCKndl1riMHjZZ6sNP/fzVZDI+6cM4yYV6yxraAyEOK5iyq/2QIGQa3NjScl
-----END CERTIFICATE-----
@@ -59,8 +59,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Security\Certificates\intune-intermediate-ca.pem" /> <EmbeddedResource Include="Security\Certificates\intune-root-*.pem" />
<EmbeddedResource Include="Security\Certificates\intune-root-ca.pem" /> <EmbeddedResource Include="Security\Certificates\intune-intermediate-*.pem" />
<EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.gather-rules.json" /> <EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.gather-rules.json" />
<EmbeddedResource Include="..\..\..\rules\dist\analyze-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.analyze-rules.json" /> <EmbeddedResource Include="..\..\..\rules\dist\analyze-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.analyze-rules.json" />
<EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Functions.Rules.ime-log-patterns.json" /> <EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Functions.Rules.ime-log-patterns.json" />
@@ -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,16 @@ 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)
// Files are picked up by name prefix from Security/Certificates/, so adding a
// future Intune root or intermediate is a drop-in (no code change).
// Duplicates (same thumbprint) are deduped at load time.
private static readonly Lazy<X509Certificate2[]> _intuneRootCerts =
new(() => LoadIntuneCerts("intune-root-"));
private static readonly Lazy<X509Certificate2[]> _intuneIntermediateCerts =
new(() => LoadIntuneCerts("intune-intermediate-"));
private class CachedValidationResult private class CachedValidationResult
{ {
@@ -89,43 +97,66 @@ namespace AutopilotMonitor.Functions.Security
logger?.LogDebug($"Certificate validation cache MISS for thumbprint {thumbprint}, performing full validation"); logger?.LogDebug($"Certificate validation cache MISS for thumbprint {thumbprint}, performing full validation");
// 1. Check expiry // 1. Check expiry. X509Certificate2.NotBefore/NotAfter return Local time on .NET;
// normalize to UTC before comparing to DateTime.UtcNow or the result is wrong on
// hosts whose TZ != UTC (e.g. test runs on a CEST dev box).
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (now < certificate.NotBefore || now > certificate.NotAfter) var notBeforeUtc = certificate.NotBefore.ToUniversalTime();
var notAfterUtc = certificate.NotAfter.ToUniversalTime();
if (now < notBeforeUtc || now > notAfterUtc)
{ {
return new CertificateValidationResult return new CertificateValidationResult
{ {
IsValid = false, IsValid = false,
ErrorMessage = $"Certificate expired or not yet valid (NotBefore={certificate.NotBefore}, NotAfter={certificate.NotAfter})", ErrorMessage = $"Certificate expired or not yet valid (NotBefore={notBeforeUtc:u}, NotAfter={notAfterUtc:u})",
Thumbprint = thumbprint Thumbprint = thumbprint
}; };
} }
// 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 a 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 +166,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,45 +221,59 @@ namespace AutopilotMonitor.Functions.Security
} }
/// <summary> /// <summary>
/// Loads Intune CA certificates from embedded PEM resources. /// Loads all certs whose embedded resource filename starts with the given prefix.
/// Files: Security/Certificates/intune-intermediate-ca.pem /// Multiple PEM blocks per file are supported (cross-signed bundles). Duplicates
/// Security/Certificates/intune-root-ca.pem /// are deduped by thumbprint, so a file accidentally duplicated under another name
/// is harmless. Roles are filename-segregated:
/// intune-root-*.pem -> pinned trust anchors (CustomTrustStore)
/// intune-intermediate-*.pem -> chain bridge candidates (ExtraStore)
/// </summary> /// </summary>
private static X509Certificate2[] LoadIntuneCaCertificates() private static X509Certificate2[] LoadIntuneCerts(string fileNamePrefix)
{ {
const string resourceNamespace = "AutopilotMonitor.Functions.Security.Certificates.";
var assembly = Assembly.GetExecutingAssembly(); var assembly = Assembly.GetExecutingAssembly();
var resourceNames = new[] var resourcePrefix = resourceNamespace + fileNamePrefix;
{
"AutopilotMonitor.Functions.Security.Certificates.intune-intermediate-ca.pem",
"AutopilotMonitor.Functions.Security.Certificates.intune-root-ca.pem"
};
List<X509Certificate2> certs = []; var resourceNames = assembly.GetManifestResourceNames()
.Where(n => n.StartsWith(resourcePrefix, StringComparison.Ordinal)
&& n.EndsWith(".pem", StringComparison.OrdinalIgnoreCase))
.OrderBy(n => n, StringComparer.Ordinal);
const string begin = "-----BEGIN CERTIFICATE-----";
const string end = "-----END CERTIFICATE-----";
var seenThumbprints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var certs = new List<X509Certificate2>();
foreach (var resourceName in resourceNames) foreach (var resourceName in resourceNames)
{ {
using var stream = assembly.GetManifestResourceStream(resourceName); using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null) if (stream == null) continue;
{
// Resource not found - not fatal, chain validation will fall back to OS store
continue;
}
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var pem = reader.ReadToEnd(); var pem = reader.ReadToEnd();
// Skip placeholder files that haven't been filled with a real cert yet
if (pem.Contains("PLACEHOLDER_REPLACE_WITH_BASE64")) if (pem.Contains("PLACEHOLDER_REPLACE_WITH_BASE64"))
continue; continue;
try int idx = 0;
while ((idx = pem.IndexOf(begin, idx, StringComparison.Ordinal)) >= 0)
{ {
var cert = X509Certificate2.CreateFromPem(pem); var endIdx = pem.IndexOf(end, idx, StringComparison.Ordinal);
certs.Add(cert); if (endIdx < 0) break;
} var block = pem.Substring(idx, endIdx + end.Length - idx);
catch try
{ {
// Malformed PEM - skip silently, chain validation continues with OS store var cert = X509Certificate2.CreateFromPem(block);
if (seenThumbprints.Add(cert.Thumbprint))
certs.Add(cert);
else
cert.Dispose();
}
catch
{
// Malformed block - skip; chain.Build() will fail closed if no roots remain.
}
idx = endIdx + end.Length;
} }
} }
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEfTCCAmWgAwIBAgIQQt4O1ZNyWaBPqw1mUq1F8jANBgkqhkiG9w0BAQsFADA4
MTYwNAYDVQQDEy1NaWNyb3NvZnQgSW50dW5lIFJvb3QgQ2VydGlmaWNhdGlvbiBB
dXRob3JpdHkwHhcNMjMwNTA1MDAwMDAwWhcNMjYwNTA1MDAwMDAwWjApMScwJQYD
VQQDEx5NaWNyb3NvZnQgSW50dW5lIE1ETSBEZXZpY2UgQ0EwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQDYOy/IV8f+BmH5y9/zJUv5oROn3MEQua39r03K
7fgAqwEsAFtU/4ZWnFbriFSx0xZsDkZxxUy9BlOQZ+2sr/eJ2j6uI6CocR4woRkt
JR6pQ5EXdsJb9lZOfV9+q6PwLnAIBQTTpUq+XtE8T1MwwGQ5NWW1JqQMDPo3nR0T
h14tDQNrLtCPe7c2f+qaG2x+uRvWGMgTmxtGpYiipQB8upnInAZZ5wVdhu+/GMZg
TSYJvn+3Px33NOB+0mpVanq7CwzyXA+dqGio8sZJDfOS1fPYDKqPj52l3rct3doa
2o98gPHWaDs7Rj+GiAKeidUkN3uhQUyNfi8+YJEHb8OVGjEFAgMBAAGjgZEwgY4w
HQYDVR0OBBYEFC5aTr/lv8IEGDqYYmHaaj3AsHw4MB8GA1UdIwQYMBaAFCtJzrEH
JUyd5gqT85C5MPfmRJ+SMBAGCSsGAQQBgjcVAQQDAgEAMA4GA1UdDwEB/wQEAwIB
hjAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMA0G
CSqGSIb3DQEBCwUAA4ICAQCoci1EyidjS6xqi3S37/TJdIGqM0OcvTsi12WPIVxA
MFRaF638dgu9FrRpTDCquc06lBHtIfBWCU7VFkGIGGIZ54L4en/dGi0ZBXjv4vfp
RQ+LsoJZnYtCF3MbvlPXfQr3nk1k6m7c1utsuSzL0VgeEJJ++5KXlMRHrFdlXXm5
N8MJNw78I/e7sbwNZxvA4UdxnEpqHeKDj1+nppANJDKrobIp9WzmYCFA8Qu3bX/N
g9OnxLnpGi8mCIvPCkPu3oo1RtHxjf/tQtdSE4V1/8mggYLNNftjWk6ElqA+gRGY
gJCssJrfG/bRYk+9ZQjavu3oG0B+SD7j3UeAOQqoOaPi8faF95tpgpVfJqQ6bm9w
qBdODWfE4TYIPd0QG5nsYr86JThEtsL36U9j74wC33+4YHV4UviUiycyQiQQCSIF
xSY2u9cLpFO4rjM+Qe/n8KWQURTW8i3wUFReVrMp9l+y70GklU/HHkXv8SDtFi6Q
0+aK2duUhbHr+8P2nPuPNetfryVsdE+RVL4pFWEfIvbOKOI5EQj3Bh4/XkbhQtZk
+FR7VkUIm5A9Kp+OzsQADkYhWE1N4xL/ysfRWmOcjB1MT1m7Zfie3YsR7j+rY4NL
b+snuHqqpYRDSZpngZAJgRYHI1xSUxOk+zJ8XyHG+eyeZdfwlrWLKuMJEFw4pczs
0Q==
-----END CERTIFICATE-----
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEfTCCAmWgAwIBAgIQ6w2J4bS8K69IHzmhs1IQ2TANBgkqhkiG9w0BAQsFADA4
MTYwNAYDVQQDEy1NaWNyb3NvZnQgSW50dW5lIFJvb3QgQ2VydGlmaWNhdGlvbiBB
dXRob3JpdHkwHhcNMjUwOTE1MDAwMDAwWhcNMjgwOTE1MDAwMDAwWjApMScwJQYD
VQQDEx5NaWNyb3NvZnQgSW50dW5lIE1ETSBEZXZpY2UgQ0EwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQDQJdsb4/U/bHNQZxCYN+9luarjMFhbBsBgGUnf
SZw3IzfvXuwvg/hyctwPDGjNh83L+sVDMXuHJ7yRtCtCcI/bUutR8eplET9GZJjt
yINcf373QkCJRn5NwRU7UNuy19LbCQmYWxbgEORgrTV/pQAMFULc5hjEtyXnMe+e
VNocKsjXn0FnJCT5IOqZZpTIa6Lb6T52jWX3JAU//4DaVl8JWMkZ8r4UptYUfWQ0
uCdvfEpb3+M3tlWT0LWfoSQ7EpLxrW1zCCEHsEAnhC4m7Dvk9rw9GiYwl+UCSU03
RkjXXSssHOqcIkhaBigzLCIOKy0apa+7iWc/2rnnTf73PGVxAgMBAAGjgZEwgY4w
HQYDVR0OBBYEFI6yWPs2UI68iis4PMNGBJFySSUaMB8GA1UdIwQYMBaAFLI/34iA
ekb9SNronyGSCloICRftMBAGCSsGAQQBgjcVAQQDAgEAMA4GA1UdDwEB/wQEAwIB
hjAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMA0G
CSqGSIb3DQEBCwUAA4ICAQApSLtPpgvRt/wu1gRll2PHYXm984XTGVePkfcZd7Sh
FfG3G21SnAoVYL8jRwgIwqOKpdE07dq+09tELdP2EBJff4uhdHyBJxC40lzJeOK5
ClzHMhdKq/HQ0dnbdfqqr5b54mkEuQubcZFCtd2e+3hcMi0gaHbKhUO53RhU32yt
Hy980mcnlfOzrhTyzY7hDZIkWO5mTqp0Ygxlb4w1nh4/PVXWnZPFKd6d2Nbgj34n
olNMa53R7xFGKj2o1NMTS2ITjTSo0wzdifo41keNIIIjGrPokaINIuDJyvUNDZuR
vcpNXSpQ83/hAo5F9kmgnetGXxJYUoTJRw1WAGE/33lXxKD9/5BN1CJjwZlBVt6j
7gVaoELMn64t3pw1zrd+iycCmXW0RmZpLDUEO99SlXKp2N2+qPizh+3Z8RsQBp+u
obiPbOcpBRTGdj9j90+JvGJSTrohLn6ViRIuiqYKjzFPEKehmFV/yOt40i0MEQW0
kXoApaYPOhlEY2pdDZARsRcJf8Qa3cVJPqbfgb2gg5kcyjtgZYibEnsYCQJcY0+J
lemmBQCSEIPqYV33ydz8MUKBweaAREediHGhdQVMmEmuxUTIqxTv2ADcxUSjgQHE
JXxiQKCWL91HVoetR9NDWHFZBNCdBcZ3TGPX/BoH/a8duNW1RHficqDVOm9pDdDU
Bw==
-----END CERTIFICATE-----
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFaTCCA1GgAwIBAgIQWhvaqZFB9KVNpmVRc71GWjANBgkqhkiG9w0BAQsFADA4
MTYwNAYDVQQDEy1NaWNyb3NvZnQgSW50dW5lIFJvb3QgQ2VydGlmaWNhdGlvbiBB
dXRob3JpdHkwHhcNMjEwODEyMDAwMDAwWhcNMjYwODEyMDAwMDAwWjA4MTYwNAYD
VQQDEy1NaWNyb3NvZnQgSW50dW5lIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
dHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDQL63MwZDlW02yCipf
dAUGn5Q8sSIc6zjdTkdCRh2SZGVFVLCgFr/EULGw3q7xaOS/mTA2I+koZM+95JvE
xfsIy+S7I7rLQjYNuJZVXl0s1xj4hfewF1jk8nWjdEwYfZkbZ+/6pYTOtHg/U3tV
seT63tg6Vc94dkUpOmN4tlyuie2qbMbc1VmAyyITcoIFtRspdE9qW1NnwgzGJz/Q
KaL2H7LtGyNGwXhH4jSwZa8ZNcwKjJ7wdaG2SNxOgrZsCHv272vugNiGEy4yYwGU
CyGOAksOaHEobW87Y92s/q4/5beBa979ZCeH4VSpRo2LcQFH9ZM9r05VpFimg8Pu
pcOl2g1WcuBj1MBBjT/YPaVgbZUp9wvqqefhW6m/JGTYaPJ4YrnMnrzem6kwGP5K
3xwHWL/5ANTjWDx6+b7dZ5f1AKf2DQFaxslMk4CqTElABI8Te1QzTG/QOmSzewtV
cwcWGo4F7yJyBbnYcqvN5XJWURFjecuoTvyxPG3R2ljfTKSyJhrTkP0hw3Zxr3cn
pY0ZtK80mKE0A9pFG597Qy3q/9TJNRtDjeQrr7DCPwQ8cvfeyT568J+hUyAeN7PV
hOV/OXkWXZND9bbkYKNE4ebfiNTGCeJX9iIGjZfbzz86eDqEVpgHcJstnWN6VWbR
qkrK/p6m5fqYNZCMAN+g4OyHZQIDAQABo28wbTAdBgNVHQ4EFgQUK0nOsQclTJ3m
CpPzkLkw9+ZEn5IwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQEw
FgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZI
hvcNAQELBQADggIBAGrkO1h+8wKHJHCMf/jRz5DhvigyDOyohx2LlbhL/i/0uSka
/u/m9uhi0UXXYX9Y15tpRUjZDqtQAjpnFePNcxe3/RRp5kuqVHaMyOHShjTTJ6YD
hKhlZP37qSBuWv+x2RsUrPzVdpFyljJWyJ1yruBJqgJ2sq3lskb8cO94fhcc2StT
uO9aB+0YY2ce5OHtOgj39Enx+PRCpweIodhAKdZuTVAX1M4qeBJD87Gg/7b4VfaS
aM6Frzrh9VuylCM258Xx1CYGgzTYJCcvCYOHA74nS3XigalsKonbdVHUEac+4D7i
P33JSlV1wlxYPPJqayiBam21YtSHmdJKV3pwkFbvlX2+pNioX86E48YaNz4faq3v
Cl4xHqMfVfOOG8QLiOnNlHsBKsffD420CKi2SJaKETPJnOLG61265jiT4Yr1mUeW
G+tQTquFeFdTTSGfToyXE58IMLhI19hQtf/2HU9aZK/vJsjWPYKCucPCXwQZA2Kk
Z/RT8HsSPPet3GyP3gL0nzfacohJ7RKwClE82exXgiGK/UAFqvEL9pwbLJtX4Hx/
+OhT4zQ9CSppKjSBDIRR8gV6G2HY5gWKXq9K+/Dv+m1APPWsR1kTsqy+tAQPRxlK
7bDsXt5GawcCP+FM0vJwd5O+ZPB3x5VLG5OLv48cCRUxf1alMQSfsJz/r2L4
-----END CERTIFICATE-----
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFaTCCA1GgAwIBAgIQWDldDibkr6lFMz6rqUVDRDANBgkqhkiG9w0BAQsFADA4
MTYwNAYDVQQDEy1NaWNyb3NvZnQgSW50dW5lIFJvb3QgQ2VydGlmaWNhdGlvbiBB
dXRob3JpdHkwHhcNMjUwOTE1MDAwMDAwWhcNMzAwOTE1MDAwMDAwWjA4MTYwNAYD
VQQDEy1NaWNyb3NvZnQgSW50dW5lIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
dHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDpcRMWOOO5oDbbLnOV
2SnaqN5NuQoEaE7nIUuH/3dKtqL/ZSRqvpNgKUyISfM/hpuq8V8JlYW/cutwg+B8
0WGXLkFHMgYhJGYxgDiwNbfZ0tk64zUpidzYgj1PeNWkKOVIspvVBiMj3co92LQl
yK8CL0V4oSeD1LpR8fncdiZtYyWKZssDE1fSG/TH/wTAQHywZJ4cwKV2itgVr1Qw
F5yShozUerrwmQ+QnEb/5gthJzKleb8lUW1FtHCaMWiEVhflUpQCDJzYgvZJOhFQ
6l0CD+eR1Oz6scDj/UxJPBEvNKB4G5m1b93gYKZ10mtK0a3Qi4df5A+8P44AhDI6
zaG2GrmF7EX6vLFOVdPC1d3MKFoApJ3ylMeknwqRlzZj0HB/SjhzyvVbLI+BPVDy
8AvM+w8cJAtV3Etyrflpmc+dEzS8I1RTO1soEuMZ6/8osg2iPAH7rV6KwbLitQvo
4thwVSw0x+hERl+M4vKnq7Vs789yC5Nb1zZ6MGTXp3tRiuG40BBOY+6FdBS0aQAQ
+UmsnRxSMTrOC9CKCYFuFYxwDn9J+VvzMNhJ4VlR6e/R7KoJNVV7KWF+OfDcsxxI
qLw3RcD1dsKKW+kl33K5XW7Q0Ra7KcMiLsvqHffhrYsgmllTGtpCgDLGl1H1vP9/
TM31bLEc6udQX5xOdcht0VmewQIDAQABo28wbTAdBgNVHQ4EFgQUsj/fiIB6Rv1I
2uifIZIKWggJF+0wDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQEw
FgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZI
hvcNAQELBQADggIBAJjQPngeOpKjhd0eouyDSlWzYMyTLEgrQwKc3XGL4el/AjB1
bOTaSPs8Y7jLUQ62Za51qyijacWSHBbrOh77We5kUf4UvFcCOKl7KdgBfgqEKJKM
3/r6SIFMouc0DhWO82Yjs2IOwOuEoKXagZRHfhHGMHFvnj6ebb1uGaPRvxdMVi70
kEoPysCavQbDahVTKuFySo1iK2BtRDYKJYUZRLTON3NfuVb6AaIUCQpAB8+0qDdR
VGV0nblmecLJ+pyCmfrWKFoRHiqV69WuAweiCX6qUxpokzwpM78fuaFnDFzihxzo
FuLbv+79MiupYSsC73OrF9ABNyc215jhmkM1NNNYtEH7Dj0JZz5o5GQ/ROH4Q2jU
2mSbeB0EOpiW1tiUzt7/R/wRcYvg6XSlwfapmE0EjrW2iUwywkU2+qGHvNQeuoIw
ILZ2eSBiaYZW97P49GNBpsliPGmpv9N5I35132b8CfwXpSBmqTTcN0UMBUKgZAV2
AUqjNml8B7bZw8h++CybzMdHOy+8uNnwO/MWsozqXz5SIeRW6YeWqyHTwbfmkJzM
VKuSBFrSgP9peUmzmpMFiqZ0yb9Rp5AhKx+kBEBAJAq4UEIV9Dxcw1hgiz00g7Ne
wBokEfeVKpYAyVLPiMvkT/HNZ0mnKeTES1xYONSV27UVzQHhQ6CxAKXiPS8Y
-----END CERTIFICATE-----