Skip to content

Fix X509 validity comparisons depending on process time zone#129885

Open
koen-lee wants to merge 5 commits into
dotnet:mainfrom
koen-lee:followup-109039-avoid-localtime-caches
Open

Fix X509 validity comparisons depending on process time zone#129885
koen-lee wants to merge 5 commits into
dotnet:mainfrom
koen-lee:followup-109039-avoid-localtime-caches

Conversation

@koen-lee

Copy link
Copy Markdown
Contributor

Follow-up to #129394.

Problem

X.509 validity periods (NotBefore/NotAfter) are absolute UTC instants by
definition (RFC 5280). The internal PAL layer decodes that instant and
then immediately converts it to a local-kind DateTime.
Each hop through local time is a chance to get the time zone wrong, and because
X509Certificate2 instances are long-lived, a value cached under one time zone can
later be read under different rules.

Tests

Two new tests in ChainTests.TimeZone.Linux.cs, covering the managed paths
rather than the OpenSSL chain:

  • FindByTimeValid_StableAcrossTimeZoneChange: a cert inside its validity window
    keeps matching Find(FindByTimeValid) across the change.
  • CreateNesting_StableAcrossTimeZoneChange: a leaf that nests inside its issuer's
    validity keeps passing CertificateRequest.Create's nesting check across the
    change.

Both fail on main and pass after the ICertificatePalCore change.
Linux is chosen as test platform because it allows programmatic TZ change, but the caching issue exists on all platforms.

Fix

Retype the internal PAL contract to carry the UTC instant as DateTimeOffset
instead of round-tripping through local time:

  • ICertificatePalCore.NotBefore/NotAfterDateTimeOffset, returned directly
    by all four backends (Windows, Apple, Android, OpenSSL) instead of via
    ToLocalTime()/FromFileTime().
  • X509Certificate's lazy cache (GetNotBefore()/GetNotAfter() =>
    GetNotBeforeUtc()/GetNotAfterUtc()) stores the UTC instant; local time is
    projected fresh at the one remaining public boundary
    (X509Certificate2.NotBefore/NotAfter, unchanged DateTimeKind.Local).
  • CertificateRequest's nesting check, the Apple chain processor's verification
    time, and the OpenSSL CRL cache now compare in UTC throughout.
  • IFindPal's FindByTimeValid/NotYetValid/Expired => DateTimeOffset, closing
    out the one remaining internal DateTime surface in the find path and deleting
    ManagedCertificateFinder's now-unnecessary Local/UTC normalization shim.

This is an internal-contract change only. X509Certificate2.NotBefore/NotAfter
and X509Certificate2Collection.Find's DateTime parameter are unchanged; no
public API changes.

koen-lee added 3 commits June 26, 2026 09:15
FindByTimeValid and CertificateRequest.Create's issuer-nesting check
compare certificate validity against local-kind DateTime, so a
mid-process timezone change can flip the result even though the
underlying certificate validity instant hasn't moved. These tests fail
today; the following commits fix the underlying comparisons.
X.509 validity periods are absolute instants encoded in UTC. The PAL
layer was decoding that UTC instant correctly and then immediately
converting it to a local-kind DateTime, forcing every internal
consumer to convert back to compare correctly. Converting to local
time and back is exactly the antipattern behind dotnet#109039: values that
are fundamentally UTC instants get round-tripped through local time,
so results depend on the machine's time zone and DST.

Have every PAL backend (Windows, Apple, Android, OpenSSL) expose the
UTC DateTimeOffset it already computes, and update internal consumers
(X509Certificate's lazy cache, CertificateRequest's issuer-nesting
check, ManagedCertificateFinder's time-based Find, the Apple chain
processor's verification-time field, the OpenSSL CRL cache) to compare
in UTC instead of local time. The public X509Certificate2.NotBefore/
NotAfter properties are unchanged (DateTimeKind.Local), projected from
the cached UTC instant at the single public boundary.

This turns the regression tests added in the previous commit green.
FindByTimeValid/NotYetValid/Expired were the last internal PAL surface
still typed as DateTime for a validity comparison, requiring
ManagedCertificateFinder to keep an explicit Local-vs-Utc
normalization shim around an otherwise UTC-based comparison. Retype
the interface and its implementations (Windows native find,
ManagedCertificateFinder shared by Android/Apple/OpenSSL) to take
DateTimeOffset directly, deleting the now-unnecessary normalization
helper. The public X509Certificate2Collection.Find(...) contract is
unchanged: callers still pass a DateTime, converted to DateTimeOffset
at the FindPal.cs boundary via the implicit operator.
Copilot AI review requested due to automatic review settings June 26, 2026 08:07
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Jun 26, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @bartonjs, @vcsjones, @dotnet/area-system-security
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates System.Security.Cryptography.X509Certificates internals so certificate validity instants (NotBefore/NotAfter) are carried and compared as UTC instants (via DateTimeOffset) instead of being cached/compared through local time, preventing mid-process time zone changes from affecting correctness.

Changes:

  • Retypes internal PAL validity properties (ICertificatePalCore.NotBefore/NotAfter) and time-based find paths (IFindPal.FindByTime*) to use DateTimeOffset for stable UTC comparisons.
  • Updates platform backends (Windows/Apple/Android/OpenSSL) and key consumers (managed find, CRL cache, CertificateRequest nesting check) to compare validity in UTC.
  • Adds Linux-only regression tests validating stability across an in-process TZ change for both chain and managed certificate validity paths.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.TimeZone.Linux.cs Adds Linux RemoteExecutor tests to validate time-validity stability across TZ changes.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs Projects cached UTC validity to local at the public property boundary.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate.cs Changes lazy validity cache to store UTC instants as DateTimeOffset.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/OpenSslX509ChainProcessor.cs Passes DateTimeOffset verification time through revocation/CRL path.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/OpenSslX509CertificateReader.cs Returns OpenSSL validity instants as DateTimeOffset (UTC).
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/OpenSslCrlCache.cs Uses DateTimeOffset for CRL cache expiration comparisons/logging.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/ManagedCertificateFinder.cs Compares validity using cached UTC instants (GetNotBeforeUtc/GetNotAfterUtc).
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/IFindPal.cs Retypes time-based find contract to DateTimeOffset.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/ICertificatePalCore.cs Retypes PAL validity contract to DateTimeOffset.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/FindPal.Windows.cs Converts find-by-time to FILETIME using UTC instants.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/ChainPal.Apple.cs Updates Apple chain processing to store verification time as DateTimeOffset.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/CertificateRequest.cs Performs issuer/leaf nesting validity checks in UTC.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/CertificatePal.Windows.cs Returns Windows validity as DateTimeOffset from FILETIME.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/CertificateData.ManagedDecode.cs Keeps decoded validity as DateTimeOffset (UTC instant).
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/AppleCertificatePal.TempExportPal.cs Updates PAL forwarders to DateTimeOffset validity properties.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/AppleCertificatePal.cs Returns Apple validity instants directly as DateTimeOffset.
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/AndroidCertificatePal.cs Returns Android validity instants directly as DateTimeOffset.
src/libraries/Common/src/Interop/Windows/Crypt32/Interop.CERT_INFO.cs Adds FILETIME → DateTimeOffset conversion helper for Windows PAL.


internal static FILETIME FromDateTime(DateTime dt)
{
long fileTime = dt.ToFileTime();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll complete the refactor and make this method obsolete.

Comment on lines 26 to 29
private volatile bool _lazyKeyAlgorithmParametersCreated;
private DateTime _lazyNotBefore = DateTime.MinValue;
private DateTime _lazyNotAfter = DateTime.MinValue;
private DateTimeOffset _lazyNotBefore = DateTimeOffset.MinValue;
private DateTimeOffset _lazyNotAfter = DateTimeOffset.MinValue;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, this is a thing. I'll use an unambiguous long instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous DateTime values were 64-bit values not written with Volatile, so changing them to a long doesn't need to use Volatile (it isn't changing anything about torn state).

And since neither X509Certificate nor X509Certificate2 are documented as thread-safe, hoops don't need to be jumped through.

@@ -280,21 +280,21 @@ public string SignatureAlgorithm
}
}

public DateTime NotAfter
public DateTimeOffset NotAfter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the value ultimately being stored here is now UTC Ticks, I'd change the cert PAL to be public long NotAfterUtcTicks and avoid a lot of "turn this into a DateTimeOffset, then back into a long, then back into a DateTimeOffset".

Plus, a long return fits in a register and the DateTimeOffset doesn't.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Security community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants