Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -185,5 +186,76 @@ public void RemoveBadCert(string cacheKey, ILoggerAdapter logger)
logger?.Verbose(() => $"[PersistentCert] Error removing from persistent cache: {ex.Message}");
}
}

/// <summary>
/// Returns <see langword="true"/> if the cert's embedded public key does not match the
/// public key currently in the associated CNG container, indicating the container was
/// regenerated (e.g. by KeyGuard on reboot) while the cert on disk still references the
/// old key material.
/// </summary>
internal static bool IsCertKeyOrphaned(X509Certificate2 cert, ILoggerAdapter logger)
{
if (cert is null)
return true;

try
{
using var rsaKey = cert.GetRSAPrivateKey();
if (rsaKey is not RSACng rsaCng)
{
// Non-CNG key (e.g. software CSP) — cannot perform KG container check; accept.
return false;
}
Comment thread
Robbie-Microsoft marked this conversation as resolved.

return !PublicKeyMatchesCert(rsaCng, cert, logger);
}
catch (CryptographicException ex)
{
logger?.Verbose(() =>
$"[PersistentCert] Cannot load private key for orphan check: {ex.Message}. Treating cert as unusable.");
return true;
}
}

/// <summary>
/// Returns <see langword="true"/> if the public key exported from <paramref name="containerKey"/>
/// matches the public key embedded in <paramref name="cert"/>.
/// A mismatch means the container holds different key material than when the cert was issued.
/// </summary>
/// <remarks>
/// Check 3 from the original proposal — comparing the CNG container's
/// <c>NCRYPT_LAST_MODIFIED_PROPERTY</c> against the cert's <c>NotBefore</c> — is
/// intentionally omitted. Both Check 3 and this modulus comparison detect the same event:
/// KeyGuard regenerating the key in the container post-reboot. This check is definitive:
/// two independently generated RSA keys sharing a modulus is computationally infeasible,
/// so a mismatch conclusively means the container was regenerated. Check 3 is a heuristic
/// with a known false-negative window (a reboot occurring within one minute of cert
/// issuance), and adds no coverage that this check does not already provide.
/// </remarks>
internal static bool PublicKeyMatchesCert(RSACng containerKey, X509Certificate2 cert, ILoggerAdapter logger)
{
try
{
var containerParams = containerKey.ExportParameters(includePrivateParameters: false);
using var certPubKey = cert.GetRSAPublicKey();
if (certPubKey is null)
return false;

var certParams = certPubKey.ExportParameters(includePrivateParameters: false);

return containerParams.Modulus is not null
&& certParams.Modulus is not null
&& containerParams.Modulus.AsSpan().SequenceEqual(certParams.Modulus)
&& containerParams.Exponent is not null
&& certParams.Exponent is not null
&& containerParams.Exponent.AsSpan().SequenceEqual(certParams.Exponent);
}
catch (CryptographicException ex)
{
logger?.Verbose(() =>
$"[PersistentCert] Public key export failed during orphan check: {ex.Message}. Treating cert as orphaned.");
return false;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ public bool Read(string alias, out CertificateCacheValue value, ILoggerAdapter l
continue;
}

// Skip certs whose CNG container key no longer matches the cert's public key.
// This detects orphaned certs left on disk after a reboot regenerates the KG per-boot key.
if (MtlsBindingCache.IsCertKeyOrphaned(candidate, logger))
{
logger.Verbose(() => "[PersistentCert] Candidate skipped: CNG container key does not match cert public key (orphaned post-reboot).");
continue;
}

if (candidate.NotAfter > bestNotAfter)
{
best?.Dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -843,5 +843,83 @@ private static bool WaitForAliasCount(string alias, int expected, int retries =
}

#endregion

#region IsCertKeyOrphaned tests

[TestMethod]
public void IsCertKeyOrphaned_ReturnsTrue_For_NullCert()
{
// Arrange (no setup needed)

// Act
bool result = MtlsBindingCache.IsCertKeyOrphaned(null, null);

// Assert
Assert.IsTrue(result, "Null cert should be treated as orphaned.");
}

[TestMethod]
public void IsCertKeyOrphaned_ReturnsFalse_For_ValidCert()
{
WindowsOnly();

// Arrange - cert whose private key in the CNG container matches the cert's embedded public key
using var cert = CreateSelfSignedCert(TimeSpan.FromDays(14), "CN=ValidCertOrphanTest");
var logger = Substitute.For<ILoggerAdapter>();

// Act
bool result = MtlsBindingCache.IsCertKeyOrphaned(cert, logger);

// Assert
Assert.IsFalse(result, "A cert whose private key matches its embedded public key should not be considered orphaned.");
}

[TestMethod]
public void PublicKeyMatchesCert_ReturnsTrue_When_KeyMatchesCert()
{
WindowsOnly();

// Arrange - cert created with key1; pass key1 as the container key
using var key1 = new RSACng(2048);

var req = new System.Security.Cryptography.X509Certificates.CertificateRequest(
new X500DistinguishedName("CN=KeyMatchTest"),
key1,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(14));

// Act
bool result = MtlsBindingCache.PublicKeyMatchesCert(key1, cert, null);

// Assert
Assert.IsTrue(result, "The key used to create the cert should match the cert's embedded public key.");
}

[TestMethod]
public void PublicKeyMatchesCert_ReturnsFalse_When_ModulusMismatch()
{
WindowsOnly();

// Arrange - cert created with key1, but we pass key2 as the container key
// (simulates post-reboot KG regeneration: same container, new key material)
using var key1 = new RSACng(2048);
using var key2 = new RSACng(2048);

var req = new System.Security.Cryptography.X509Certificates.CertificateRequest(
new X500DistinguishedName("CN=KeyMismatchTest"),
key1,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(14));

// Act
bool result = MtlsBindingCache.PublicKeyMatchesCert(key2, cert, null);

// Assert
Assert.IsFalse(result, "A different key than the one used to create the cert should produce a modulus mismatch.");
}

#endregion
}
}
Loading