Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Code coverage for Server-side OCSP stapling #97099

Merged
merged 5 commits into from
Jan 31, 2024
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 @@ -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<SslProtocols, SafeSslContextHandle> SslContexts;
internal readonly SafeX509Handle CertificateHandle;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
// 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;

var ctx = ctxFactory(true);
var ocsp = await ctx.GetOcspResponseAsync();
rzikm marked this conversation as resolved.
Show resolved Hide resolved
Assert.Null(ocsp);
});
}

[Fact]
public async Task FetchOcspResponse_NoExpiration_Success()
{
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
{
intermediate.RevocationExpiration = null;

var ctx = ctxFactory(false);
var 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);

var ctx = ctxFactory(false);
var ocsp = await ctx.GetOcspResponseAsync();
Assert.NotNull(ocsp);

// should cache and return the same
var 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;

var ctx = ctxFactory(false);
var ocsp = await ctx.GetOcspResponseAsync();
Assert.Null(ocsp);
rzikm marked this conversation as resolved.
Show resolved Hide resolved
});
}

[Fact]
public async Task FetchOcspResponse_FirstInvalidThenValid()
{
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
{
responder.RespondKind = RespondKind.Invalid;

var ctx = ctxFactory(false);
var 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);

var ctx = ctxFactory(false);
bartonjs marked this conversation as resolved.
Show resolved Hide resolved
var 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
var ocsp2 = ctx.GetOcspResponseNoWaiting();
Assert.Equal(ocsp, ocsp2);

await RetryHelper.ExecuteAsync(async () =>
{
var ocsp3 = await ctx.GetOcspResponseAsync();
Assert.NotNull(ocsp3);
Assert.NotEqual(ocsp, ocsp3);
}, maxAttempts: 5, backoffFunc: i => (i + 1) * 200 /* ms */);
});
}

[Fact]
[OuterLoop("Takes about 3 seconds")]
rzikm marked this conversation as resolved.
Show resolved Hide resolved
public async Task RefreshOcspResponse_AfterExpiration()
{
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
{
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddSeconds(1);

var 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,
var ocsp = ctx.GetOcspResponseNoWaiting();
Assert.Null(ocsp);

// subsequent call will return the new response
var 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);

var ctx = ctxFactory(false);
var ocsp = await ctx.GetOcspResponseAsync();
Assert.NotNull(ocsp);

responder.RespondKind = RespondKind.Invalid;
for (int i = 0; i < 3; i++)
{
await Task.Delay(SslStreamCertificateContext.RefreshAfterFailureBackOffInterval);
var 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 () =>
{
var 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<bool, SslStreamCertificateContext> 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<bool, SslStreamCertificateContext> 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}\"";
}
}
Loading
Loading