Skip to content

Commit 9429e43

Browse files
authored
Code coverage for Server-side OCSP stapling (dotnet#97099)
* Include necessary sources in unit test project * Add tests * Remove usage of var * Code review feedback * Remove one OuterLoop attribute
1 parent 05d0922 commit 9429e43

File tree

3 files changed

+383
-5
lines changed

3 files changed

+383
-5
lines changed

src/libraries/System.Net.Security/src/System/Net/Security/SslStreamCertificateContext.Linux.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ namespace System.Net.Security
1717
{
1818
public partial class SslStreamCertificateContext
1919
{
20+
internal static TimeSpan DefaultOcspRefreshInterval => TimeSpan.FromHours(24);
21+
internal static TimeSpan MinRefreshBeforeExpirationInterval => TimeSpan.FromMinutes(5);
22+
internal static TimeSpan RefreshAfterFailureBackOffInterval => TimeSpan.FromSeconds(5);
23+
2024
private const bool TrimRootCertificate = true;
2125
internal readonly ConcurrentDictionary<SslProtocols, SafeSslContextHandle> SslContexts;
2226
internal readonly SafeX509Handle CertificateHandle;
@@ -260,8 +264,8 @@ partial void AddRootCertificate(X509Certificate2? rootCertificate, ref bool tran
260264
_ocspUrls[i] = tmp;
261265
}
262266

263-
DateTimeOffset nextCheckA = DateTimeOffset.UtcNow.AddDays(1);
264-
DateTimeOffset nextCheckB = expiration.AddMinutes(-5);
267+
DateTimeOffset nextCheckA = DateTimeOffset.UtcNow.Add(DefaultOcspRefreshInterval);
268+
DateTimeOffset nextCheckB = expiration.Subtract(MinRefreshBeforeExpirationInterval);
265269

266270
_ocspResponse = ret;
267271
_ocspExpiration = expiration;
@@ -285,7 +289,7 @@ partial void AddRootCertificate(X509Certificate2? rootCertificate, ref bool tran
285289
// All download attempts failed, don't try again for 5 seconds.
286290
// This backoff will be applied only if the OCSP staple is not expired.
287291
// If it is expired, we will force-refresh it during next GetOcspResponseAsync call.
288-
_nextDownload = DateTimeOffset.UtcNow.AddSeconds(5);
292+
_nextDownload = DateTimeOffset.UtcNow.Add(RefreshAfterFailureBackOffInterval);
289293
}
290294
return ret;
291295
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.IO;
5+
using System.Net.Test.Common;
6+
using System.Security.Authentication;
7+
using System.Security.Cryptography;
8+
using System.Security.Cryptography.X509Certificates;
9+
using System.Security.Cryptography.X509Certificates.Tests.Common;
10+
using System.Runtime.CompilerServices;
11+
using System.Threading.Tasks;
12+
using System.Net.Security;
13+
14+
using Xunit;
15+
16+
namespace System.Net.Security.Tests;
17+
18+
public class SslStreamCertificateContextOcspLinuxTests
19+
{
20+
[Fact]
21+
public async Task OfflineContext_NoFetchOcspResponse()
22+
{
23+
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
24+
{
25+
intermediate.RevocationExpiration = null;
26+
27+
SslStreamCertificateContext ctx = ctxFactory(true);
28+
byte[] ocsp = await ctx.GetOcspResponseAsync();
29+
Assert.Null(ocsp);
30+
});
31+
}
32+
33+
[Fact]
34+
public async Task FetchOcspResponse_NoExpiration_Success()
35+
{
36+
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
37+
{
38+
intermediate.RevocationExpiration = null;
39+
40+
SslStreamCertificateContext ctx = ctxFactory(false);
41+
byte[] ocsp = await ctx.GetOcspResponseAsync();
42+
Assert.NotNull(ocsp);
43+
});
44+
}
45+
46+
[Theory]
47+
[InlineData(PkiOptions.OcspEverywhere)]
48+
[InlineData(PkiOptions.OcspEverywhere | PkiOptions.IssuerAuthorityHasDesignatedOcspResponder)]
49+
public async Task FetchOcspResponse_WithExpiration_Success(PkiOptions pkiOptions)
50+
{
51+
await SimpleTest(pkiOptions, async (root, intermediate, endEntity, ctxFactory, responder) =>
52+
{
53+
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddDays(1);
54+
55+
SslStreamCertificateContext ctx = ctxFactory(false);
56+
byte[] ocsp = await ctx.GetOcspResponseAsync();
57+
Assert.NotNull(ocsp);
58+
59+
// should cache and return the same
60+
byte[] ocsp2 = await ctx.GetOcspResponseAsync();
61+
Assert.Equal(ocsp, ocsp2);
62+
});
63+
}
64+
65+
[Fact]
66+
public async Task FetchOcspResponse_Expired_ReturnsNull()
67+
{
68+
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
69+
{
70+
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddMinutes(-5);
71+
72+
SslStreamCertificateContext ctx = ctxFactory(false);
73+
byte[] ocsp = await ctx.GetOcspResponseAsync();
74+
Assert.Null(ocsp);
75+
});
76+
}
77+
78+
[Fact]
79+
public async Task FetchOcspResponse_FirstInvalidThenValid()
80+
{
81+
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
82+
{
83+
responder.RespondKind = RespondKind.Invalid;
84+
85+
SslStreamCertificateContext ctx = ctxFactory(false);
86+
byte[] ocsp = await ctx.GetOcspResponseAsync();
87+
Assert.Null(ocsp);
88+
89+
responder.RespondKind = RespondKind.Normal;
90+
ocsp = await ctx.GetOcspResponseAsync();
91+
Assert.NotNull(ocsp);
92+
});
93+
}
94+
95+
[Fact]
96+
public async Task RefreshOcspResponse_BeforeExpiration()
97+
{
98+
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
99+
{
100+
// Set the expiration to be in the future, but close enough that a refresh gets triggered
101+
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.Add(SslStreamCertificateContext.MinRefreshBeforeExpirationInterval);
102+
103+
SslStreamCertificateContext ctx = ctxFactory(false);
104+
byte[] ocsp = await ctx.GetOcspResponseAsync();
105+
Assert.NotNull(ocsp);
106+
107+
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddDays(1);
108+
109+
// first call will dispatch a download and return the cached response, the first call after
110+
// the pending download finishes will return the updated response
111+
byte[] ocsp2 = ctx.GetOcspResponseNoWaiting();
112+
Assert.Equal(ocsp, ocsp2);
113+
114+
await RetryHelper.ExecuteAsync(async () =>
115+
{
116+
byte[] ocsp3 = await ctx.GetOcspResponseAsync();
117+
Assert.NotNull(ocsp3);
118+
Assert.NotEqual(ocsp, ocsp3);
119+
}, maxAttempts: 5, backoffFunc: i => (i + 1) * 200 /* ms */);
120+
});
121+
}
122+
123+
[Fact]
124+
public async Task RefreshOcspResponse_AfterExpiration()
125+
{
126+
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
127+
{
128+
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddSeconds(1);
129+
130+
SslStreamCertificateContext ctx = ctxFactory(false);
131+
132+
await Task.Delay(2000);
133+
134+
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.AddDays(1);
135+
136+
// The cached OCSP is expired, so the first call will dispatch a download and return the cached response,
137+
byte[] ocsp = ctx.GetOcspResponseNoWaiting();
138+
Assert.Null(ocsp);
139+
140+
// subsequent call will return the new response
141+
byte[] ocsp2 = await ctx.GetOcspResponseAsync();
142+
Assert.NotNull(ocsp2);
143+
});
144+
}
145+
146+
[Fact]
147+
[OuterLoop("Takes about 15 seconds")]
148+
public async Task RefreshOcspResponse_FirstInvalidThenValid()
149+
{
150+
Assert.True(SslStreamCertificateContext.MinRefreshBeforeExpirationInterval > SslStreamCertificateContext.RefreshAfterFailureBackOffInterval * 4, "Backoff interval is too long");
151+
152+
await SimpleTest(PkiOptions.OcspEverywhere, async (root, intermediate, endEntity, ctxFactory, responder) =>
153+
{
154+
// Set the expiration to be in the future, but close enough that a refresh gets triggered
155+
intermediate.RevocationExpiration = DateTimeOffset.UtcNow.Add(SslStreamCertificateContext.MinRefreshBeforeExpirationInterval);
156+
157+
SslStreamCertificateContext ctx = ctxFactory(false);
158+
byte[] ocsp = await ctx.GetOcspResponseAsync();
159+
Assert.NotNull(ocsp);
160+
161+
responder.RespondKind = RespondKind.Invalid;
162+
for (int i = 0; i < 3; i++)
163+
{
164+
await Task.Delay(SslStreamCertificateContext.RefreshAfterFailureBackOffInterval);
165+
byte[] ocsp2 = await ctx.GetOcspResponseAsync();
166+
Assert.Equal(ocsp, ocsp2);
167+
}
168+
169+
// after responder comes back online, the staple is eventually refreshed
170+
responder.RespondKind = RespondKind.Normal;
171+
await RetryHelper.ExecuteAsync(async () =>
172+
{
173+
byte[] ocsp3 = await ctx.GetOcspResponseAsync();
174+
Assert.NotNull(ocsp3);
175+
Assert.NotEqual(ocsp, ocsp3);
176+
}, maxAttempts: 5, backoffFunc: i => (i + 1) * 200 /* ms */);
177+
});
178+
}
179+
180+
private delegate Task RunSimpleTest(
181+
CertificateAuthority root,
182+
CertificateAuthority intermediate,
183+
X509Certificate2 endEntity,
184+
Func<bool, SslStreamCertificateContext> ctxFactory,
185+
RevocationResponder responder);
186+
187+
private static async Task SimpleTest(
188+
PkiOptions pkiOptions,
189+
RunSimpleTest callback,
190+
[CallerMemberName] string callerName = null,
191+
bool pkiOptionsInTestName = true)
192+
{
193+
BuildPrivatePki(
194+
pkiOptions,
195+
out RevocationResponder responder,
196+
out CertificateAuthority root,
197+
out CertificateAuthority intermediate,
198+
out X509Certificate2 endEntity,
199+
callerName,
200+
pkiOptionsInSubject: pkiOptionsInTestName);
201+
202+
using (responder)
203+
using (root)
204+
using (intermediate)
205+
using (endEntity)
206+
using (X509Certificate2 rootCert = root.CloneIssuerCert())
207+
using (X509Certificate2 intermediateCert = intermediate.CloneIssuerCert())
208+
{
209+
if (pkiOptions.HasFlag(PkiOptions.RootAuthorityHasDesignatedOcspResponder))
210+
{
211+
using (RSA tmpKey = RSA.Create())
212+
using (X509Certificate2 tmp = root.CreateOcspSigner(
213+
BuildSubject("A Root Designated OCSP Responder", callerName, pkiOptions, true),
214+
tmpKey))
215+
{
216+
root.DesignateOcspResponder(tmp.CopyWithPrivateKey(tmpKey));
217+
}
218+
}
219+
220+
if (pkiOptions.HasFlag(PkiOptions.IssuerAuthorityHasDesignatedOcspResponder))
221+
{
222+
using (RSA tmpKey = RSA.Create())
223+
using (X509Certificate2 tmp = intermediate.CreateOcspSigner(
224+
BuildSubject("An Intermediate Designated OCSP Responder", callerName, pkiOptions, true),
225+
tmpKey))
226+
{
227+
intermediate.DesignateOcspResponder(tmp.CopyWithPrivateKey(tmpKey));
228+
}
229+
}
230+
231+
X509Certificate2Collection additionalCerts = new();
232+
additionalCerts.Add(intermediateCert);
233+
additionalCerts.Add(rootCert);
234+
235+
Func<bool, SslStreamCertificateContext> factory = offline => SslStreamCertificateContext.Create(
236+
endEntity,
237+
additionalCerts,
238+
offline,
239+
trust: null);
240+
241+
await callback(root, intermediate, endEntity, factory, responder);
242+
}
243+
}
244+
245+
internal static void BuildPrivatePki(
246+
PkiOptions pkiOptions,
247+
out RevocationResponder responder,
248+
out CertificateAuthority rootAuthority,
249+
out CertificateAuthority intermediateAuthority,
250+
out X509Certificate2 endEntityCert,
251+
[CallerMemberName] string testName = null,
252+
bool registerAuthorities = true,
253+
bool pkiOptionsInSubject = false)
254+
{
255+
bool issuerRevocationViaCrl = pkiOptions.HasFlag(PkiOptions.IssuerRevocationViaCrl);
256+
bool issuerRevocationViaOcsp = pkiOptions.HasFlag(PkiOptions.IssuerRevocationViaOcsp);
257+
bool endEntityRevocationViaCrl = pkiOptions.HasFlag(PkiOptions.EndEntityRevocationViaCrl);
258+
bool endEntityRevocationViaOcsp = pkiOptions.HasFlag(PkiOptions.EndEntityRevocationViaOcsp);
259+
260+
Assert.True(
261+
issuerRevocationViaCrl || issuerRevocationViaOcsp ||
262+
endEntityRevocationViaCrl || endEntityRevocationViaOcsp,
263+
"At least one revocation mode is enabled");
264+
265+
CertificateAuthority.BuildPrivatePki(pkiOptions, out responder, out rootAuthority, out intermediateAuthority, out endEntityCert, testName, registerAuthorities, pkiOptionsInSubject);
266+
}
267+
268+
private static string BuildSubject(
269+
string cn,
270+
string testName,
271+
PkiOptions pkiOptions,
272+
bool includePkiOptions)
273+
{
274+
if (includePkiOptions)
275+
{
276+
return $"CN=\"{cn}\", O=\"{testName}\", OU=\"{pkiOptions}\"";
277+
}
278+
279+
return $"CN=\"{cn}\", O=\"{testName}\"";
280+
}
281+
}

0 commit comments

Comments
 (0)