diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamCertificateContext.Linux.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamCertificateContext.Linux.cs index b62030a0c1df1..0a5b5c89004b7 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamCertificateContext.Linux.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamCertificateContext.Linux.cs @@ -17,6 +17,10 @@ namespace System.Net.Security { public partial class SslStreamCertificateContext { + internal static TimeSpan DefaultOcspRefreshInterval => TimeSpan.FromHours(24); + internal static TimeSpan MinRefreshBeforeExpirationInterval => TimeSpan.FromMinutes(5); + internal static TimeSpan RefreshAfterFailureBackOffInterval => TimeSpan.FromSeconds(5); + private const bool TrimRootCertificate = true; internal readonly ConcurrentDictionary SslContexts; internal readonly SafeX509Handle CertificateHandle; @@ -260,8 +264,8 @@ partial void AddRootCertificate(X509Certificate2? rootCertificate, ref bool tran _ocspUrls[i] = tmp; } - DateTimeOffset nextCheckA = DateTimeOffset.UtcNow.AddDays(1); - DateTimeOffset nextCheckB = expiration.AddMinutes(-5); + DateTimeOffset nextCheckA = DateTimeOffset.UtcNow.Add(DefaultOcspRefreshInterval); + DateTimeOffset nextCheckB = expiration.Subtract(MinRefreshBeforeExpirationInterval); _ocspResponse = ret; _ocspExpiration = expiration; @@ -285,7 +289,7 @@ partial void AddRootCertificate(X509Certificate2? rootCertificate, ref bool tran // All download attempts failed, don't try again for 5 seconds. // This backoff will be applied only if the OCSP staple is not expired. // If it is expired, we will force-refresh it during next GetOcspResponseAsync call. - _nextDownload = DateTimeOffset.UtcNow.AddSeconds(5); + _nextDownload = DateTimeOffset.UtcNow.Add(RefreshAfterFailureBackOffInterval); } return ret; } diff --git a/src/libraries/System.Net.Security/tests/UnitTests/SslStreamCertificateContextOcspLinuxTests.cs b/src/libraries/System.Net.Security/tests/UnitTests/SslStreamCertificateContextOcspLinuxTests.cs new file mode 100644 index 0000000000000..a4d705fa454d3 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/UnitTests/SslStreamCertificateContextOcspLinuxTests.cs @@ -0,0 +1,281 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Net.Test.Common; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.X509Certificates.Tests.Common; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Net.Security; + +using Xunit; + +namespace System.Net.Security.Tests; + +public class SslStreamCertificateContextOcspLinuxTests +{ + [Fact] + public async Task OfflineContext_NoFetchOcspResponse() + { + await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) => + { + intermediate.RevocationExpiration = null; + + SslStreamCertificateContext ctx = ctxFactory(true); + byte[] ocsp = await ctx.GetOcspResponseAsync(); + Assert.Null(ocsp); + }); + } + + [Fact] + public async Task FetchOcspResponse_NoExpiration_Success() + { + await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) => + { + intermediate.RevocationExpiration = null; + + SslStreamCertificateContext ctx = ctxFactory(false); + byte[] ocsp = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp); + }); + } + + [Theory] + [InlineData(PkiOptions.OcspEverywhere)] + [InlineData(PkiOptions.OcspEverywhere | PkiOptions.IssuerAuthorityHasDesignatedOcspResponder)] + public async Task FetchOcspResponse_WithExpiration_Success(PkiOptions pkiOptions) + { + await SimpleTest(pkiOptions, async (root, intermediate, endEntity, ctxFactory, responder) => + { + intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddDays(1); + + SslStreamCertificateContext ctx = ctxFactory(false); + byte[] ocsp = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp); + + // should cache and return the same + byte[] ocsp2 = await ctx.GetOcspResponseAsync(); + Assert.Equal(ocsp, ocsp2); + }); + } + + [Fact] + public async Task FetchOcspResponse_Expired_ReturnsNull() + { + await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) => + { + intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddMinutes(-5); + + SslStreamCertificateContext ctx = ctxFactory(false); + byte[] ocsp = await ctx.GetOcspResponseAsync(); + Assert.Null(ocsp); + }); + } + + [Fact] + public async Task FetchOcspResponse_FirstInvalidThenValid() + { + await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) => + { + responder.RespondKind = RespondKind.Invalid; + + SslStreamCertificateContext ctx = ctxFactory(false); + byte[] ocsp = await ctx.GetOcspResponseAsync(); + Assert.Null(ocsp); + + responder.RespondKind = RespondKind.Normal; + ocsp = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp); + }); + } + + [Fact] + public async Task RefreshOcspResponse_BeforeExpiration() + { + await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) => + { + // Set the expiration to be in the future, but close enough that a refresh gets triggered + intermediate.RevocationExpiration = DateTimeOffset.UtcNow.Add(SslStreamCertificateContext.MinRefreshBeforeExpirationInterval); + + SslStreamCertificateContext ctx = ctxFactory(false); + byte[] ocsp = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp); + + intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddDays(1); + + // first call will dispatch a download and return the cached response, the first call after + // the pending download finishes will return the updated response + byte[] ocsp2 = ctx.GetOcspResponseNoWaiting(); + Assert.Equal(ocsp, ocsp2); + + await RetryHelper.ExecuteAsync(async () => + { + byte[] ocsp3 = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp3); + Assert.NotEqual(ocsp, ocsp3); + }, maxAttempts: 5, backoffFunc: i => (i + 1) * 200 /* ms */); + }); + } + + [Fact] + public async Task RefreshOcspResponse_AfterExpiration() + { + await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) => + { + intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddSeconds(1); + + SslStreamCertificateContext ctx = ctxFactory(false); + + await Task.Delay(2000); + + intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddDays(1); + + // The cached OCSP is expired, so the first call will dispatch a download and return the cached response, + byte[] ocsp = ctx.GetOcspResponseNoWaiting(); + Assert.Null(ocsp); + + // subsequent call will return the new response + byte[] ocsp2 = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp2); + }); + } + + [Fact] + [OuterLoop("Takes about 15 seconds")] + public async Task RefreshOcspResponse_FirstInvalidThenValid() + { + Assert.True(SslStreamCertificateContext.MinRefreshBeforeExpirationInterval > SslStreamCertificateContext.RefreshAfterFailureBackOffInterval * 4, "Backoff interval is too long"); + + await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) => + { + // Set the expiration to be in the future, but close enough that a refresh gets triggered + intermediate.RevocationExpiration = DateTimeOffset.UtcNow.Add(SslStreamCertificateContext.MinRefreshBeforeExpirationInterval); + + SslStreamCertificateContext ctx = ctxFactory(false); + byte[] ocsp = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp); + + responder.RespondKind = RespondKind.Invalid; + for (int i = 0; i < 3; i++) + { + await Task.Delay(SslStreamCertificateContext.RefreshAfterFailureBackOffInterval); + byte[] ocsp2 = await ctx.GetOcspResponseAsync(); + Assert.Equal(ocsp, ocsp2); + } + + // after responder comes back online, the staple is eventually refreshed + responder.RespondKind = RespondKind.Normal; + await RetryHelper.ExecuteAsync(async () => + { + byte[] ocsp3 = await ctx.GetOcspResponseAsync(); + Assert.NotNull(ocsp3); + Assert.NotEqual(ocsp, ocsp3); + }, maxAttempts: 5, backoffFunc: i => (i + 1) * 200 /* ms */); + }); + } + + private delegate Task RunSimpleTest( + CertificateAuthority root, + CertificateAuthority intermediate, + X509Certificate2 endEntity, + Func ctxFactory, + RevocationResponder responder); + + private static async Task SimpleTest( + PkiOptions pkiOptions, + RunSimpleTest callback, + [CallerMemberName] string callerName = null, + bool pkiOptionsInTestName = true) + { + BuildPrivatePki( + pkiOptions, + out RevocationResponder responder, + out CertificateAuthority root, + out CertificateAuthority intermediate, + out X509Certificate2 endEntity, + callerName, + pkiOptionsInSubject: pkiOptionsInTestName); + + using (responder) + using (root) + using (intermediate) + using (endEntity) + using (X509Certificate2 rootCert = root.CloneIssuerCert()) + using (X509Certificate2 intermediateCert = intermediate.CloneIssuerCert()) + { + if (pkiOptions.HasFlag(PkiOptions.RootAuthorityHasDesignatedOcspResponder)) + { + using (RSA tmpKey = RSA.Create()) + using (X509Certificate2 tmp = root.CreateOcspSigner( + BuildSubject("A Root Designated OCSP Responder", callerName, pkiOptions, true), + tmpKey)) + { + root.DesignateOcspResponder(tmp.CopyWithPrivateKey(tmpKey)); + } + } + + if (pkiOptions.HasFlag(PkiOptions.IssuerAuthorityHasDesignatedOcspResponder)) + { + using (RSA tmpKey = RSA.Create()) + using (X509Certificate2 tmp = intermediate.CreateOcspSigner( + BuildSubject("An Intermediate Designated OCSP Responder", callerName, pkiOptions, true), + tmpKey)) + { + intermediate.DesignateOcspResponder(tmp.CopyWithPrivateKey(tmpKey)); + } + } + + X509Certificate2Collection additionalCerts = new(); + additionalCerts.Add(intermediateCert); + additionalCerts.Add(rootCert); + + Func factory = offline => SslStreamCertificateContext.Create( + endEntity, + additionalCerts, + offline, + trust: null); + + await callback(root, intermediate, endEntity, factory, responder); + } + } + + internal static void BuildPrivatePki( + PkiOptions pkiOptions, + out RevocationResponder responder, + out CertificateAuthority rootAuthority, + out CertificateAuthority intermediateAuthority, + out X509Certificate2 endEntityCert, + [CallerMemberName] string testName = null, + bool registerAuthorities = true, + bool pkiOptionsInSubject = false) + { + bool issuerRevocationViaCrl = pkiOptions.HasFlag(PkiOptions.IssuerRevocationViaCrl); + bool issuerRevocationViaOcsp = pkiOptions.HasFlag(PkiOptions.IssuerRevocationViaOcsp); + bool endEntityRevocationViaCrl = pkiOptions.HasFlag(PkiOptions.EndEntityRevocationViaCrl); + bool endEntityRevocationViaOcsp = pkiOptions.HasFlag(PkiOptions.EndEntityRevocationViaOcsp); + + Assert.True( + issuerRevocationViaCrl || issuerRevocationViaOcsp || + endEntityRevocationViaCrl || endEntityRevocationViaOcsp, + "At least one revocation mode is enabled"); + + CertificateAuthority.BuildPrivatePki(pkiOptions, out responder, out rootAuthority, out intermediateAuthority, out endEntityCert, testName, registerAuthorities, pkiOptionsInSubject); + } + + private static string BuildSubject( + string cn, + string testName, + PkiOptions pkiOptions, + bool includePkiOptions) + { + if (includePkiOptions) + { + return $"CN=\"{cn}\", O=\"{testName}\", OU=\"{pkiOptions}\""; + } + + return $"CN=\"{cn}\", O=\"{testName}\""; + } +} diff --git a/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj b/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj index 62e8134e3377d..e3e81ecb61a3d 100644 --- a/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj +++ b/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj @@ -40,7 +40,97 @@ Link="CommonTest\System\Net\SslProtocolSupport.cs" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - +