Closed
Description
In some cases, we are not able to verify OCSP staple returned to us from the OCSP server specified in the certificate. This will lead to servers not sending OCSP staple.
Repro
using System.Buffers;
using System.Diagnostics;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
// examples which fail
// var serverUrl = "broker-uswe-01-msit-aks.broker.skype.com";
// var serverUrl = "ep-euwe-01-prod-aks.flightproxy.teams.microsoft.com";
var serverUrl = Environment.GetCommandLineArgs().Skip(1).FirstOrDefault();
X509Certificate2? cert = null;
X509Certificate2? issuer = null;
if (serverUrl != null)
{
// download certificates from the specified server
(cert, issuer) = DownloadCertifficates(serverUrl);
File.WriteAllText("cert.pem", cert.ExportCertificatePem());
File.WriteAllText("issuer.pem", issuer.ExportCertificatePem());
}
else
{
System.Console.WriteLine("No server URL provided, loading from disk.");
cert = new X509Certificate2("cert.pem");
issuer = new X509Certificate2("issuer.pem");
}
System.Console.WriteLine($"Cert: {cert.Subject}");
System.Console.WriteLine($"Issuer: {issuer.Subject}");
using var ocspRequest = Interop.Crypto.X509BuildOcspRequest(cert.Handle, issuer.Handle);
List<string> ocspUrls = GetOcspUrls(cert);
if (ocspUrls.Count == 0)
{
System.Console.WriteLine("No OCSP URLs found in certificate");
return;
}
foreach (var url in ocspUrls)
{
var ocspResponse = DownloadOcspStaple(ocspRequest, url);
if (!Interop.Crypto.X509DecodeOcspToExpiration(ocspResponse, ocspRequest, cert.Handle, issuer.Handle, out DateTimeOffset expiration))
{
System.Console.WriteLine("Failed to decode OCSP response");
continue;
}
System.Console.WriteLine($"Decode successful, expiration: {expiration}");
}
static byte[] DownloadOcspStaple(SafeOcspRequestHandle ocspRequest, string ocspUrl)
{
byte[] rentedBytes = ArrayPool<byte>.Shared.Rent(Interop.Crypto.GetOcspRequestDerSize(ocspRequest));
int encodingSize = Interop.Crypto.EncodeOcspRequest(ocspRequest, rentedBytes);
ArraySegment<byte> encoded = new ArraySegment<byte>(rentedBytes, 0, encodingSize);
ArraySegment<char> rentedChars = UrlBase64Encoding.RentEncode(encoded);
string url = MakeUrl(ocspUrl, rentedChars);
System.Console.WriteLine($"Fetching staple from {url}");
byte[] ocsp = new HttpClient().GetByteArrayAsync(url).Result ?? Array.Empty<byte>();
System.Console.WriteLine($"Received staple: {ocsp.Length} B");
File.WriteAllBytes("ocsp-req.der", encoded.ToArray());
File.WriteAllBytes("ocsp-res.der", ocsp);
return ocsp ?? Array.Empty<byte>();
}
static (X509Certificate2 cert, X509Certificate2 issuer) DownloadCertifficates(string serverUrl)
{
Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
s.Connect(serverUrl, 443);
X509Certificate2? cert = null;
X509Certificate2? issuer = null;
var sslStream = new System.Net.Security.SslStream(new NetworkStream(s), false, (sender, certificate, chain, errors) =>
{
cert = certificate as X509Certificate2;
issuer = new X509Certificate2(chain!.ChainElements[1]!.Certificate);
return true;
});
sslStream.AuthenticateAsClient(serverUrl);
Debug.Assert(cert != null);
Debug.Assert(issuer != null);
return (cert, issuer);
}
//
// the rest of the code below is taken verbatim from dotnet/runtime repo, methods are from SslStreamCertificateContext.Linux.cs
//
static List<string> GetOcspUrls(X509Certificate2 cert)
{
var ocspUrls = new List<string>();
foreach (X509Extension ext in cert.Extensions)
{
if (ext is X509AuthorityInformationAccessExtension aia)
{
foreach (string entry in aia.EnumerateOcspUris())
{
if (Uri.TryCreate(entry, UriKind.Absolute, out Uri? uri))
{
if (uri.Scheme == "http")
{
ocspUrls.Add(entry);
}
}
}
break;
}
}
return ocspUrls;
}
static string MakeUrl(string baseUri, ArraySegment<char> encodedRequest)
{
Debug.Assert(baseUri.Length > 0);
Debug.Assert(encodedRequest.Count > 0);
// From https://datatracker.ietf.org/doc/html/rfc6960:
//
// An OCSP request using the GET method is constructed as follows:
//
// GET {url}/{url-encoding of base-64 encoding of the DER encoding of
// the OCSPRequest}
//
// where {url} may be derived from the value of the authority
// information access extension in the certificate being checked for
// revocation
// Since the certificate isn't expected to have a slash at the end, but might,
// use a custom concat over Uri's built-in combining constructor.
string uriString;
if (baseUri.EndsWith('/'))
{
uriString = string.Concat(baseUri, encodedRequest.AsSpan());
}
else
{
uriString = string.Concat(baseUri, "/", encodedRequest.AsSpan());
}
return uriString;
}
internal static class Libraries
{
internal const string CryptoNative = "libSystem.Security.Cryptography.Native.OpenSsl";
}
internal static partial class Interop
{
internal static partial class Crypto
{
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_OcspRequestDestroy")]
internal static partial void OcspRequestDestroy(IntPtr ocspReq);
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_GetOcspRequestDerSize")]
internal static partial int GetOcspRequestDerSize(SafeOcspRequestHandle req);
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_EncodeOcspRequest")]
internal static partial int EncodeOcspRequest(SafeOcspRequestHandle req, byte[] buf);
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_X509BuildOcspRequest")]
internal static partial SafeOcspRequestHandle X509BuildOcspRequest(IntPtr subject, IntPtr issuer);
[LibraryImport(Libraries.CryptoNative)]
private static unsafe partial int CryptoNative_X509DecodeOcspToExpiration(
byte* buf,
int len,
SafeOcspRequestHandle req,
IntPtr subject,
IntPtr issuer,
ref long expiration);
internal static unsafe bool X509DecodeOcspToExpiration(
ReadOnlySpan<byte> buf,
SafeOcspRequestHandle request,
IntPtr x509Subject,
IntPtr x509Issuer,
out DateTimeOffset expiration)
{
long timeT = 0;
int ret;
fixed (byte* pBuf = buf)
{
ret = CryptoNative_X509DecodeOcspToExpiration(
pBuf,
buf.Length,
request,
x509Subject,
x509Issuer,
ref timeT);
}
if (ret == 1)
{
if (timeT != 0)
{
expiration = DateTimeOffset.FromUnixTimeSeconds(timeT);
}
else
{
// Something went wrong during the determination of when the response
// should not be used any longer.
// Half an hour sounds fair?
expiration = DateTimeOffset.UtcNow.AddMinutes(30);
}
return true;
}
Debug.Assert(ret == 0, $"Unexpected response from X509DecodeOcspToExpiration: {ret}");
expiration = DateTimeOffset.MinValue;
return false;
}
[LibraryImport(Libraries.CryptoNative)]
private static partial SafeOcspResponseHandle CryptoNative_DecodeOcspResponse(ref byte buf, int len);
internal static SafeOcspResponseHandle DecodeOcspResponse(ReadOnlySpan<byte> buf)
{
return CryptoNative_DecodeOcspResponse(
ref MemoryMarshal.GetReference(buf),
buf.Length);
}
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_OcspResponseDestroy")]
internal static partial void OcspResponseDestroy(IntPtr ocspReq);
}
}
internal sealed class SafeOcspRequestHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeOcspRequestHandle()
: base(true)
{
}
protected override bool ReleaseHandle()
{
Interop.Crypto.OcspRequestDestroy(handle);
handle = IntPtr.Zero;
return true;
}
}
internal sealed class SafeOcspResponseHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeOcspResponseHandle()
: base(true)
{
}
protected override bool ReleaseHandle()
{
Interop.Crypto.OcspResponseDestroy(handle);
handle = IntPtr.Zero;
return true;
}
}
// Class of safe handle which uses 0 or -1 as an invalid handle.
public abstract class SafeHandleZeroOrMinusOneIsInvalid : SafeHandle
{
protected SafeHandleZeroOrMinusOneIsInvalid(bool ownsHandle) : base(IntPtr.Zero, ownsHandle)
{
}
public override bool IsInvalid => handle == IntPtr.Zero || handle == new IntPtr(-1);
}
/// <summary>
/// This class provides URL-encoded-Base64, which is distinct from the base64url encoding.
/// </summary>
internal static class UrlBase64Encoding
{
internal static ArraySegment<char> RentEncode(ReadOnlySpan<byte> input)
{
// Every 3 bytes turns into 4 chars for the Base64 operation
int base64Len = ((input.Length + 2) / 3) * 4;
char[] base64 = ArrayPool<char>.Shared.Rent(base64Len);
if (!Convert.TryToBase64Chars(input, base64, out int charsWritten))
{
Debug.Fail($"Convert.TryToBase64 failed with {input.Length} bytes to a {base64.Length} buffer");
throw new UnreachableException();
}
Debug.Assert(charsWritten == base64Len);
// In the degenerate case every char will turn into 3 chars.
int urlEncodedLen = charsWritten * 3;
char[] urlEncoded = ArrayPool<char>.Shared.Rent(urlEncodedLen);
ReadOnlySpan<char> source = base64.AsSpan(0, base64Len);
Span<char> dest = urlEncoded;
int written = 0;
while (!source.IsEmpty)
{
int pos = source.IndexOfAny('+', '/', '=');
if (pos < 0)
{
source.CopyTo(dest);
written += source.Length;
break;
}
source.Slice(0, pos).CopyTo(dest);
source = source.Slice(pos);
dest = dest.Slice(pos);
written += pos;
dest[0] = '%';
switch (source[0])
{
case '+':
dest[1] = '2';
dest[2] = 'B';
break;
case '/':
dest[1] = '2';
dest[2] = 'F';
break;
default:
Debug.Assert(source[0] == '=');
dest[1] = '3';
dest[2] = 'D';
break;
}
source = source.Slice(1);
dest = dest.Slice(3);
written += 3;
}
ArrayPool<char>.Shared.Return(base64);
return new ArraySegment<char>(urlEncoded, 0, written);
}
}
When I dump the ocsp request and response, I can successfully verify it using openssl ocsp
:
❯ dotnet run microsoft.com
Cert: CN=microsoft.com, O=Microsoft Corporation, L=Redmond, S=WA, C=US
Issuer: CN=Microsoft Azure RSA TLS Issuing CA 03, O=Microsoft Corporation, C=US
Fetching staple from http://oneocsp.microsoft.com/ocsp/MHkwdzBQME4wTDAJBgUrDgMCGgUABBSzT%2FzqwcqNvhP9i4c4sCPz2JbzrAQU%2FglxQFUFEETYpIF1uJ4a6UoGiMgCEzMAGFWk7xSM9TwaSKgAAAAYVaSiIzAhMB8GCSsGAQUFBzABAgQSBBCdjlY2BE0VNoxkeagY4MEG
Received staple: 1819 B
Failed to decode OCSP response
❯ openssl ocsp -respin ./ocsp-res.der -issuer ./issuer.pem -reqin ./ocsp-req.der
Response verify OK
Which means we are possibly calling OCSP_basic_verify with wrong parameters.