File: System\Security\Cryptography\X509Certificates\OpenSslCrlCache.cs
Web Access
Project: src\src\libraries\System.Security.Cryptography\src\System.Security.Cryptography.csproj (System.Security.Cryptography)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Formats.Asn1;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography.Asn1;
using System.Security.Cryptography.X509Certificates.Asn1;
using System.Threading;
using Microsoft.Win32.SafeHandles;
 
namespace System.Security.Cryptography.X509Certificates
{
    internal static class OpenSslCrlCache
    {
        private static readonly string s_crlDir =
            PersistedFiles.GetUserFeatureDirectory(
                X509Persistence.CryptographyFeatureName,
                X509Persistence.CrlsSubFeatureName);
 
        private static readonly string s_ocspDir =
            PersistedFiles.GetUserFeatureDirectory(
                X509Persistence.CryptographyFeatureName,
                X509Persistence.OcspSubFeatureName);
 
        private static readonly MruCrlCache s_crlCache = new();
 
        private const ulong X509_R_CERT_ALREADY_IN_HASH_TABLE = 0x0B07D065;
 
        public static void AddCrlForCertificate(
            SafeX509Handle cert,
            SafeX509StoreHandle store,
            X509RevocationMode revocationMode,
            DateTime verificationTime,
            TimeSpan downloadTimeout)
        {
            // In Offline mode, accept any cached CRL we have.
            // "CRL is Expired" is a better match for Offline than "Could not find CRL"
            if (revocationMode != X509RevocationMode.Online)
            {
                verificationTime = DateTime.MinValue;
            }
 
            string? url = GetCdpUrl(cert);
 
            if (url == null)
            {
                return;
            }
 
            string crlFileName = GetCrlFileName(cert, url);
 
            if (OpenSslX509ChainEventSource.Log.IsEnabled())
            {
                OpenSslX509ChainEventSource.Log.CrlIdentifiersDetermined(cert, url, crlFileName);
            }
 
            if (AddCachedCrl(crlFileName, store, verificationTime))
            {
                return;
            }
 
            // Don't do any work if we're prohibited from fetching new CRLs
            if (revocationMode != X509RevocationMode.Online)
            {
                if (OpenSslX509ChainEventSource.Log.IsEnabled())
                {
                    OpenSslX509ChainEventSource.Log.CrlCheckOffline();
                }
 
                return;
            }
 
            DownloadAndAddCrl(url, crlFileName, store, downloadTimeout);
        }
 
        private static bool AddCachedCrl(string crlFileName, SafeX509StoreHandle store, DateTime verificationTime)
        {
            // OpenSSL is going to convert our input time to universal, so we should be in Local or
            // Unspecified (local-assumed).
            Debug.Assert(
                verificationTime.Kind != DateTimeKind.Utc,
                "UTC verificationTime should have been normalized to Local");
 
            if (s_crlCache.TryGetValueAndUpRef(crlFileName, out CachedCrlEntry? cacheEntry))
            {
                try
                {
                    Debug.Assert(cacheEntry is not null);
 
                    if (verificationTime < cacheEntry.Expiration)
                    {
                        if (OpenSslX509ChainEventSource.Log.IsEnabled())
                        {
                            OpenSslX509ChainEventSource.Log.CrlCacheInMemoryHit(cacheEntry.Expiration);
                        }
 
                        AttachCrl(store, cacheEntry.CrlHandle);
                        return true;
                    }
 
                    if (OpenSslX509ChainEventSource.Log.IsEnabled())
                    {
                        OpenSslX509ChainEventSource.Log.CrlCacheInMemoryExpired(verificationTime, cacheEntry.Expiration);
                    }
                }
                finally
                {
                    cacheEntry.CrlHandle.DangerousRelease();
                }
            }
            else if (OpenSslX509ChainEventSource.Log.IsEnabled())
            {
                OpenSslX509ChainEventSource.Log.CrlCacheInMemoryMiss();
            }
 
            // Check the disk cache.
            // For uncached this is the first load, for collected it's a reload,
            // for expired it's checking to see if another process has updated the disk cache.
            CachedCrlEntry? diskCacheEntry = CheckDiskCache(crlFileName, verificationTime);
 
            if (diskCacheEntry is null)
            {
                return false;
            }
 
            UpdateCacheAndAttachCrl(crlFileName, store, diskCacheEntry);
            return true;
        }
 
        private static void UpdateCacheAndAttachCrl(string crlFileName, SafeX509StoreHandle store, CachedCrlEntry newEntry)
        {
            Debug.Assert(!newEntry.CrlHandle.IsInvalid);
            CachedCrlEntry toAttach = s_crlCache.AddOrUpdateAndUpRef(crlFileName, newEntry);
 
            try
            {
                AttachCrl(store, toAttach.CrlHandle);
            }
            finally
            {
                toAttach.CrlHandle.DangerousRelease();
            }
        }
 
        private static void AttachCrl(SafeX509StoreHandle store, SafeX509CrlHandle crl)
        {
            Debug.Assert(!crl.IsInvalid);
 
            // X509_STORE_add_crl will increase the refcount on the CRL object,
            // so we don't need to worry about our copy getting cleaned up as a weak reference.
            if (!Interop.Crypto.X509StoreAddCrl(store, crl))
            {
                // Ignore error "cert already in store", throw on anything else. In any case the error queue will be cleared.
                if (X509_R_CERT_ALREADY_IN_HASH_TABLE == Interop.Crypto.ErrPeekLastError())
                {
                    Interop.Crypto.ErrClearError();
                }
                else
                {
                    throw Interop.Crypto.CreateOpenSslCryptographicException();
                }
            }
        }
 
        private static CachedCrlEntry? CheckDiskCache(string crlFileName, DateTime verificationTime)
        {
            string crlFile = GetCachedCrlPath(crlFileName);
 
            if (OpenSslX509ChainEventSource.Log.IsEnabled())
            {
                OpenSslX509ChainEventSource.Log.CrlCacheCheckStart();
            }
 
            try
            {
                return CheckDiskCacheCore(crlFile, verificationTime);
            }
            finally
            {
                if (OpenSslX509ChainEventSource.Log.IsEnabled())
                {
                    OpenSslX509ChainEventSource.Log.CrlCacheCheckStop();
                }
            }
        }
 
        private static CachedCrlEntry? CheckDiskCacheCore(string crlFile, DateTime verificationTime)
        {
            using (SafeBioHandle bio = Interop.Crypto.BioNewFile(crlFile, "rb"))
            {
                if (bio.IsInvalid)
                {
                    if (OpenSslX509ChainEventSource.Log.IsEnabled())
                    {
                        OpenSslX509ChainEventSource.Log.CrlCacheOpenError();
                    }
 
                    Interop.Crypto.ErrClearError();
                    return null;
                }
 
                SafeX509CrlHandle crl = Interop.Crypto.PemReadBioX509Crl(bio);
 
                {
                    if (crl.IsInvalid)
                    {
                        if (OpenSslX509ChainEventSource.Log.IsEnabled())
                        {
                            OpenSslX509ChainEventSource.Log.CrlCacheDecodeError();
                        }
 
                        crl.Dispose();
                        Interop.Crypto.ErrClearError();
                        return null;
                    }
 
                    // If crl.LastUpdate is in the past, downloading a new version isn't really going
                    // to help, since we can't rewind the Internet. So this is just going to fail, but
                    // at least it can fail without using the network.
                    //
                    // If crl.NextUpdate is in the past, try downloading a newer version.
                    IntPtr nextUpdatePtr = Interop.Crypto.GetX509CrlNextUpdate(crl);
                    DateTime nextUpdate;
 
                    // If there is no crl.NextUpdate, this indicates that the CA is not providing
                    // any more updates to the CRL, or they made a mistake not providing a NextUpdate.
                    // We'll cache it for a few days to cover the case it was a mistake.
                    if (nextUpdatePtr == IntPtr.Zero)
                    {
                        if (OpenSslX509ChainEventSource.Log.IsEnabled())
                        {
                            OpenSslX509ChainEventSource.Log.CrlCacheFileBasedExpiry();
                        }
 
                        try
                        {
                            nextUpdate = ExpirationTimeFromCacheFileTime(File.GetLastWriteTime(crlFile));
                        }
                        catch
                        {
                            // We couldn't determine when the CRL was last written to,
                            // so consider it expired.
                            Debug.Fail("Failed to get the last write time of the CRL file");
                            crl.Dispose();
                            return null;
                        }
                    }
                    else
                    {
                        nextUpdate = OpenSslX509CertificateReader.ExtractValidityDateTime(nextUpdatePtr);
                    }
 
                    // In the event that we're to-the-second accurate on the match, OpenSSL will consider this
                    // to be already expired.
                    if (nextUpdate <= verificationTime)
                    {
                        if (OpenSslX509ChainEventSource.Log.IsEnabled())
                        {
                            OpenSslX509ChainEventSource.Log.CrlCacheExpired(verificationTime, nextUpdate);
                        }
 
                        crl.Dispose();
                        return null;
                    }
 
                    if (OpenSslX509ChainEventSource.Log.IsEnabled())
                    {
                        OpenSslX509ChainEventSource.Log.CrlCacheAcceptedFile(nextUpdate);
                    }
 
                    return new CachedCrlEntry(crl, nextUpdate);
                }
            }
        }
 
        private static void DownloadAndAddCrl(
            string url,
            string crlFileName,
            SafeX509StoreHandle store,
            TimeSpan downloadTimeout)
        {
            CachedCrlEntry? newEntry = DownloadAndCacheCrl(url, crlFileName, downloadTimeout);
 
            if (newEntry is not null)
            {
                UpdateCacheAndAttachCrl(crlFileName, store, newEntry);
            }
        }
 
        private static CachedCrlEntry? DownloadAndCacheCrl(
            string url,
            string crlFileName,
            TimeSpan downloadTimeout)
        {
            SafeX509CrlHandle? crl = OpenSslCertificateAssetDownloader.DownloadCrl(url, downloadTimeout);
 
            // null is a valid return (e.g. no remainingDownloadTime)
            if (crl == null || crl.IsInvalid)
            {
                crl?.Dispose();
                return null;
            }
 
            IntPtr nextUpdatePtr = Interop.Crypto.GetX509CrlNextUpdate(crl);
            DateTime expiryTime;
 
            // If there is no crl.NextUpdate, this indicates that the CA is not providing
            // any more updates to the CRL, or they made a mistake not providing a NextUpdate.
            // We'll cache it for a few days to cover the case it was a mistake.
            if (nextUpdatePtr == IntPtr.Zero)
            {
                expiryTime = ExpirationTimeFromCacheFileTime(DateTime.Now);
            }
            else
            {
                expiryTime = OpenSslX509CertificateReader.ExtractValidityDateTime(nextUpdatePtr);
            }
 
            // Saving the CRL to the disk is just a performance optimization for later requests to not
            // need to use the network again, so failure to save shouldn't throw an exception or mark
            // the chain as invalid.
            try
            {
                string crlFile = GetCachedCrlPath(crlFileName, mkDir: true);
 
                using (SafeBioHandle bio = Interop.Crypto.BioNewFile(crlFile, "wb"))
                {
                    if (bio.IsInvalid || Interop.Crypto.PemWriteBioX509Crl(bio, crl) == 0)
                    {
                        // No bio, or write failed
 
                        if (OpenSslX509ChainEventSource.Log.IsEnabled())
                        {
                            OpenSslX509ChainEventSource.Log.CrlCacheWriteFailed(crlFile);
                        }
 
                        Interop.Crypto.ErrClearError();
                    }
                }
            }
            catch (UnauthorizedAccessException) { }
            catch (IOException) { }
 
            if (OpenSslX509ChainEventSource.Log.IsEnabled())
            {
                OpenSslX509ChainEventSource.Log.CrlCacheWriteSucceeded();
            }
 
            return new CachedCrlEntry(crl, expiryTime);
        }
 
        private static DateTime ExpirationTimeFromCacheFileTime(DateTime cacheFileTime)
        {
            // CA/Browser Forum says that CRLs should be updated every 4 to 7 days,
            // so recheck any cached CRL, that doesn't have a NextUpdate, every 3 days.
            return cacheFileTime.AddDays(3);
        }
 
        internal static string GetCachedOcspResponseDirectory()
        {
            return s_ocspDir;
        }
 
        private static string GetCrlFileName(SafeX509Handle cert, string crlUrl)
        {
            // X509_issuer_name_hash returns "unsigned long", which is marshalled as ulong.
            // But it only sets 32 bits worth of data, so force it down to uint just... in case.
            ulong persistentHashLong = Interop.Crypto.X509IssuerNameHash(cert);
            if (persistentHashLong == 0)
            {
                Interop.Crypto.ErrClearError();
            }
 
            uint persistentHash = unchecked((uint)persistentHashLong);
            Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
 
            // Endianness isn't important, it just needs to be consistent.
            // (Even if the same storage was used for two different endianness systems it'd stabilize at two files).
            ReadOnlySpan<byte> utf16Url = MemoryMarshal.AsBytes(crlUrl.AsSpan());
 
            if (SHA256.HashData(utf16Url, hash) != hash.Length)
            {
                Debug.Fail("HashData failed or produced an incorrect length output");
                throw new CryptographicException();
            }
 
            uint urlHash = MemoryMarshal.Read<uint>(hash);
 
            // OpenSSL's hashed filename algorithm is the 8-character hex version of the 32-bit value
            // of X509_issuer_name_hash (or X509_subject_name_hash, depending on the context).
            //
            // We mix in an 8-character hex version of the "left"-most bytes of a hash of the URL to
            // disambiguate when one Issuing Authority separates their revocation across independent CRLs.
            return $"{persistentHash:x8}.{urlHash:x8}.crl";
        }
 
        private static string GetCachedCrlPath(string localFileName, bool mkDir = false)
        {
            if (mkDir)
            {
                Directory.CreateDirectory(s_crlDir);
            }
 
            return Path.Combine(s_crlDir, localFileName);
        }
 
        private static string? GetCdpUrl(SafeX509Handle cert)
        {
            ArraySegment<byte> crlDistributionPoints =
                OpenSslX509CertificateReader.FindFirstExtension(cert, Oids.CrlDistributionPoints);
 
            if (crlDistributionPoints.Array == null)
            {
                if (OpenSslX509ChainEventSource.Log.IsEnabled())
                {
                    OpenSslX509ChainEventSource.Log.NoCdpFound(cert);
                }
 
                return null;
            }
 
            try
            {
                AsnValueReader reader = new AsnValueReader(crlDistributionPoints, AsnEncodingRules.DER);
                AsnValueReader sequenceReader = reader.ReadSequence();
                reader.ThrowIfNotEmpty();
 
                while (sequenceReader.HasData)
                {
                    DistributionPointAsn.Decode(ref sequenceReader, crlDistributionPoints, out DistributionPointAsn distributionPoint);
 
                    // Only distributionPoint is supported
                    // Only fullName is supported, nameRelativeToCRLIssuer is for LDAP-based lookup.
                    if (distributionPoint.DistributionPoint.HasValue &&
                        distributionPoint.DistributionPoint.Value.FullName != null)
                    {
                        foreach (GeneralNameAsn name in distributionPoint.DistributionPoint.Value.FullName)
                        {
                            if (name.Uri != null)
                            {
                                if (Uri.TryCreate(name.Uri, UriKind.Absolute, out Uri? uri) &&
                                    uri.Scheme == "http")
                                {
                                    return name.Uri;
                                }
                                else
                                {
                                    if (OpenSslX509ChainEventSource.Log.IsEnabled())
                                    {
                                        OpenSslX509ChainEventSource.Log.NonHttpCdpEntry(name.Uri);
                                    }
                                }
                            }
                        }
 
                        if (OpenSslX509ChainEventSource.Log.IsEnabled())
                        {
                            OpenSslX509ChainEventSource.Log.NoMatchingCdpEntry();
                        }
                    }
                }
            }
            catch (CryptographicException)
            {
                // Treat any ASN errors as if the extension was missing.
            }
            catch (AsnContentException)
            {
                // Treat any ASN errors as if the extension was missing.
            }
            finally
            {
                // The data came from a certificate, so it's public.
                CryptoPool.Return(crlDistributionPoints.Array, clearSize: 0);
            }
 
            return null;
        }
 
        // The MRU CRL cache always does a DangerousAddReference before returning the value,
        // so that neither cooperative GC pruning nor a cache-value refresh trigger ReleaseHandle
        // on a CRL entry in use.
        private sealed class MruCrlCache
        {
            // Each CRL is only a SafeHandle to the GC, but represents a non-trivial amount of
            // native memory, so keep the cache small.
            private const int MaxItems = 30;
 
            private readonly Lock _lock = new();
 
            private int _count = -1;
            private Node? _head;
            private Node? _expire;
 
            internal CachedCrlEntry AddOrUpdateAndUpRef(string key, CachedCrlEntry value)
            {
                Debug.Assert(key is not null);
                Debug.Assert(value is not null);
                Debug.Assert(value.CrlHandle is not null && !value.CrlHandle.IsInvalid);
                // Don't assert/enforce anything about expiration, because a) clock-skew, or b)
                // the caller might have a verification time that's in the past.
 
                int hashCode = key.GetHashCode();
                CachedCrlEntry ret = value;
                string? fullMemberKey = null;
                SafeX509CrlHandle? toDispose = null;
 
                lock (_lock)
                {
                    // The first time we add something, create the object to monitor for GC events.
                    if (_count < 0)
                    {
                        new GCWatcher(this);
                        _count = 0;
                    }
 
                    bool ignore = false;
 
                    if (TryGetNode(hashCode, key, out Node? current))
                    {
                        Debug.Assert(current is not null);
 
                        if (current.Value.Expiration >= value.Expiration)
                        {
                            toDispose = value.CrlHandle;
                            ret = current.Value;
                        }
                        else
                        {
                            toDispose = current.Value.CrlHandle;
                            current.Value = value;
                        }
                    }
                    else
                    {
                        Node node = new Node(hashCode, key, value);
                        node.Next = _head;
 
                        if (_count < MaxItems)
                        {
                            _count++;
                        }
                        else
                        {
                            // Because MaxItems is small, it's better to just iterate from head
                            // instead of using a doubly-linked list.
 
                            Node? previous = null;
                            Node? cur = _head;
                            Node? next = cur?.Next;
 
                            while (next is not null)
                            {
                                previous = cur;
                                cur = next;
                                next = cur.Next;
                            }
 
                            Debug.Assert(previous is not null);
                            Debug.Assert(cur is not null);
 
                            previous.Next = null;
                            toDispose = cur.Value.CrlHandle;
                            fullMemberKey = cur.Key;
                            if (cur == _expire)
                            {
                                _expire = null;
                            }
                        }
 
                        _head = node;
                    }
 
                    ret.CrlHandle.DangerousAddRef(ref ignore);
                }
 
                toDispose?.Dispose();
 
                if (fullMemberKey is not null && OpenSslX509ChainEventSource.Log.IsEnabled())
                {
                    OpenSslX509ChainEventSource.Log.CrlCacheInMemoryFull(fullMemberKey);
                }
 
                return ret;
            }
 
            internal bool TryGetValueAndUpRef(string key, [NotNullWhen(true)] out CachedCrlEntry? value)
            {
                int hashCode = key.GetHashCode();
 
                lock (_lock)
                {
                    if (TryGetNode(hashCode, key, out Node? node))
                    {
                        bool ignore = false;
                        node.Value.CrlHandle.DangerousAddRef(ref ignore);
                        value = node.Value;
                        return true;
                    }
                }
 
                value = null;
                return false;
            }
 
            private bool TryGetNode(int hashCode, string key, [NotNullWhen(true)] out Node? value)
            {
                Debug.Assert(_lock.IsHeldByCurrentThread);
 
                Node? previous = null;
                Node? current = _head;
 
                while (current is not null)
                {
                    if (current.MatchesKey(hashCode, key))
                    {
                        // If we find the expire node, move expiration to after it, so that promoting it to
                        // most recent doesn't prune the whole list.
                        //
                        // This might, of course, make _expire null.
                        if (current == _expire)
                        {
                            _expire = current.Next;
                        }
 
                        // Move the found node to the head of the list, maintaining MRU ordering.
                        if (previous != null)
                        {
                            previous.Next = current.Next;
                            current.Next = _head;
                            _head = current;
                        }
 
                        value = current;
                        return true;
                    }
 
                    previous = current;
                    current = current.Next;
                }
 
                value = null;
                return false;
            }
 
            private void PruneForGC()
            {
                // The general flow:
                // * The current head is where we expire next time.
                // * Under the lock: If there is an expire node, determine the new count by walking to it,
                //   and unlink it from the previous node.
                // * After the lock: Dispose all the values from the prune node onward.
 
                Node? prune;
                int countStart;
                int countEnd;
 
                lock (_lock)
                {
                    prune = _expire;
                    _expire = _head;
                    countStart = _count;
 
                    if (prune is null)
                    {
                        return;
                    }
 
                    if (prune == _head)
                    {
                        _count = 0;
                        _head = null;
                        _expire = null;
                    }
                    else
                    {
                        Debug.Assert(_head is not null);
                        int count = 1;
                        Node current = _head;
 
                        while (current.Next != prune && current.Next is not null)
                        {
                            count++;
                            current = current.Next;
                        }
 
                        Debug.Assert(current.Next == prune, "The prune node should be in the list");
                        current.Next = null;
                        _count = count;
                    }
 
                    countEnd = _count;
                }
 
                // `prune` and beyond are now unlinked from the list, so we can dispose its values without holding the lock.
                while (prune is not null)
                {
                    prune.Value.CrlHandle.Dispose();
                    prune = prune.Next;
                }
 
                if (OpenSslX509ChainEventSource.Log.IsEnabled())
                {
                    OpenSslX509ChainEventSource.Log.CrlCacheInMemoryPruned(countStart - countEnd, countEnd);
                }
            }
 
            private sealed class Node
            {
                private readonly int _keyHashCode;
 
                internal string Key { get; }
                internal CachedCrlEntry Value { get; set; }
                internal Node? Next { get; set; }
 
                internal Node(int hashCode, string key, CachedCrlEntry value)
                {
                    Debug.Assert(key.GetHashCode() == hashCode);
 
                    Key = key;
                    _keyHashCode = hashCode;
                    Value = value;
                }
 
                internal bool MatchesKey(int hashCode, string key)
                {
                    return _keyHashCode == hashCode && Key.Equals(key, StringComparison.Ordinal);
                }
            }
 
            private sealed class GCWatcher
            {
                private readonly MruCrlCache _owner;
 
                internal GCWatcher(MruCrlCache owner)
                {
                    _owner = owner;
                }
 
                ~GCWatcher()
                {
                    GC.ReRegisterForFinalize(this);
 
                    if (GC.GetGeneration(this) == GC.MaxGeneration)
                    {
                        try
                        {
                            _owner.PruneForGC();
                        }
                        catch
                        {
                            // Eat any exception so we don't terminate the finalizer thread.
#if DEBUG
                            // Except in DEBUG, as we really shouldn't be hitting any exceptions here.
                            throw;
#endif
                        }
                    }
                }
            }
        }
 
        private sealed class CachedCrlEntry
        {
            internal SafeX509CrlHandle CrlHandle { get; }
            internal DateTime Expiration { get; }
 
            internal CachedCrlEntry(SafeX509CrlHandle crlHandle, DateTime expiration)
            {
                CrlHandle = crlHandle;
                Expiration = expiration;
            }
        }
    }
}