.netsslopensslmonoboringssl

Mono: can't access API with self-signed certificate - MonoBtlsException: Ssl error:1000007d:SSL


I have a Mono container (mono:6.12.0.182) and it has some host.testcontainers.internal:[PORT] exposed to it. I will be using this to integration test a legacy .NET client application against a new API(.NET 10).

The setup of my test is the following:

In the container, I have a trust-self-signed.sh [api_url] command that does a web request, saves the server certificate and syncs it to the container's stores via openssl and update-ca-certificates It also tests the ability to do https requests via curl, openssl and a .NET WebClient inside Mono - only the latter one not working:

#!/bin/bash
set -euo pipefail

if [ "$#" -ne 1 ]; then
  echo "Usage: $0 <https-url>"
  exit 1
fi

URL="$1"

# Extract host and optional port
HOSTPORT=$(echo "$URL" | sed -E 's#^https?://([^/]+).*#\1#')
HOST=$(echo "$HOSTPORT" | cut -d: -f1)
PORT=$(echo "$HOSTPORT" | cut -s -d: -f2)

if [ -z "$HOST" ]; then
  echo "Could not parse host from URL: $URL"
  exit 1
fi

# Default to 443 if no port was provided
if [ -z "$PORT" ]; then
  PORT=443
fi

CERT_DIR="/usr/local/share/ca-certificates/custom"
CERT_FILE="$CERT_DIR/${HOST}_${PORT}.crt"

echo "[*] Fetching certificate from $HOST:$PORT..."
mkdir -p "$CERT_DIR"

# Fetch certificate in PEM format
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" 2>/dev/null \
  | openssl x509 -outform PEM > "$CERT_FILE"

if [ ! -s "$CERT_FILE" ]; then
  echo "Failed to fetch certificate from $HOST:$PORT"
  exit 1
fi

echo "[*] Certificate saved to $CERT_FILE"

echo "[*] Updating system-wide CA certificates..."
update-ca-certificates

echo "[*] Checking with curl..."
curl $URL
echo ""

echo "[*] Checking with openssl..."
openssl s_client -connect $HOST:$PORT -brief <<< ""

echo "[*] Checking with Mono WebClient..."
csharp -e "new System.Net.WebClient().DownloadString(\"$URL\")"

echo "[*] Done. $HOST:$PORT is now trusted system-wide."

I get the following result (output from trust-self-signed.sh script above):

[*] Fetching certificate from host.testcontainers.internal:60191...
[*] Certificate saved to /usr/local/share/ca-certificates/custom/host.testcontainers.internal_60191.crt
[*] Updating system-wide CA certificates...
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
Updating Mono key store
Mono Certificate Store Sync - version 6.12.0.182
Populate Mono certificate store from a concatenated list of certificates.
Copyright 2002, 2003 Motus Technologies. Copyright 2004-2008 Novell. BSD licensed.

Importing into legacy system store:
I already trust 137, your new list has 138
Certificate added: CN=host.testcontainers.internal
1 new root certificates were added to your trust store.
Import process completed.

Importing into BTLS system store:
I already trust 137, your new list has 138
Certificate added: CN=host.testcontainers.internal
1 new root certificates were added to your trust store.
Import process completed.
Done
done.
[*] Checking with curl...
   (A JSON response from the API printed here)
[*] Checking with openssl...
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN = host.testcontainers.internal
Hash used: SHA256
Signature type: RSA-PSS
Verification: OK
Server Temp Key: X25519, 253 bits
DONE
[*] Checking with Mono WebClient...
System.Net.WebException: Error: TrustFailure (Authentication failed, see inner exception.) ---> System.Security.Authentication.AuthenticationException: Authentication failed, see inner exception. ---> Mono.Btls.MonoBtlsException: Ssl error:1000007d:SSL routines:OPENSSL_internal:CERTIFICATE_VERIFY_FAILED
  at /build/mono-6.12.0.182/external/boringssl/ssl/handshake_client.c:1132
  at Mono.Btls.MonoBtlsContext.ProcessHandshake () [0x00048] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at Mono.Net.Security.MobileAuthenticatedStream.ProcessHandshake (Mono.Net.Security.AsyncOperationStatus status, System.Boolean renegotiate) [0x000da] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at (wrapper remoting-invoke-with-check) Mono.Net.Security.MobileAuthenticatedStream.ProcessHandshake(Mono.Net.Security.AsyncOperationStatus,bool)
  at Mono.Net.Security.AsyncHandshakeRequest.Run (Mono.Net.Security.AsyncOperationStatus status) [0x00006] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at Mono.Net.Security.AsyncProtocolRequest.ProcessOperation (System.Threading.CancellationToken cancellationToken) [0x000fc] in <33b19a7ad5234d94abf4fd9b47566616>:0 
   --- End of inner exception stack trace ---
  at Mono.Net.Security.MobileAuthenticatedStream.ProcessAuthentication (System.Boolean runSynchronously, Mono.Net.Security.MonoSslAuthenticationOptions options, System.Threading.CancellationToken cancellationToken) [0x00262] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at Mono.Net.Security.MonoTlsStream.CreateStream (System.Net.WebConnectionTunnel tunnel, System.Threading.CancellationToken cancellationToken) [0x0016a] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebConnection.CreateStream (System.Net.WebOperation operation, System.Boolean reused, System.Threading.CancellationToken cancellationToken) [0x001ba] in <33b19a7ad5234d94abf4fd9b47566616>:0 
   --- End of inner exception stack trace ---
  at System.Net.WebConnection.CreateStream (System.Net.WebOperation operation, System.Boolean reused, System.Threading.CancellationToken cancellationToken) [0x0021a] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebConnection.InitConnection (System.Net.WebOperation operation, System.Threading.CancellationToken cancellationToken) [0x00141] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebOperation.Run () [0x0009a] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebCompletionSource`1[T].WaitForCompletion () [0x00094] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.HttpWebRequest.RunWithTimeoutWorker[T] (System.Threading.Tasks.Task`1[TResult] workerTask, System.Int32 timeout, System.Action abort, System.Func`1[TResult] aborted, System.Threading.CancellationTokenSource cts) [0x000f8] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.HttpWebRequest.GetResponse () [0x00016] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebClient.GetWebResponse (System.Net.WebRequest request) [0x00000] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebClient.DownloadBits (System.Net.WebRequest request, System.IO.Stream writeStream) [0x000e6] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebClient.DownloadDataInternal (System.Uri address, System.Net.WebRequest& request) [0x00061] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebClient.DownloadString (System.Uri address) [0x00011] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at System.Net.WebClient.DownloadString (System.String address) [0x00008] in <33b19a7ad5234d94abf4fd9b47566616>:0 
  at <InteractiveExpressionClass>.Host (System.Object& $retval) [0x00006] in <e20cabee2daf48e38be397fbab53fa46>:0 
  at Mono.CSharp.Evaluator.Evaluate (System.String input, System.Object& result, System.Boolean& result_set) [0x00038] in <0ee36b7ad5084f94a05a07daa2b0352a>:0 
  at Mono.CSharpShell.Evaluate (System.String input) [0x00000] in <a37c47c2a8df41448b522ff2bc894c89>:0 

Probably not relevant but the back-end code I use to generate the certificate is the following:

ConcurrentDictionary<string, X509Certificate2> certificates = new(StringComparer.InvariantCultureIgnoreCase);
builder.ConfigureKestrel((context, kestrel) => {
    kestrel.Listen(IPAddress.Loopback, 0, listen => {
        listen.UseHttps(https => {
            https.ServerCertificateSelector = (ConnectionContext? _, string? host) => {
                if (
                    string.IsNullOrWhiteSpace(host)
                    || host.Equals("localhost", StringComparison.InvariantCultureIgnoreCase)
                )
                {
                    //Fall back to the .NET self-signed dev certificate
                    //This is never hit for the Mono test
                    return null; 
                }
                X509Certificate2 certificate = certificates.GetOrAdd(host, BuildSelfSignedServerCertificate);
                return certificate;
            };
        });
    });
});
...
public static X509Certificate2 BuildSelfSignedServerCertificate(string host)
{
    //https://stackoverflow.com/questions/42786986/how-to-create-a-valid-self-signed-x509certificate2-programmatically-not-loadin
    SubjectAlternativeNameBuilder sanBuilder = new();
    sanBuilder.AddIpAddress(IPAddress.Loopback);
    sanBuilder.AddIpAddress(IPAddress.IPv6Loopback);
    sanBuilder.AddDnsName(host);
    X500DistinguishedName distinguishedName = new($"CN={host}");
    using RSA rsa = RSA.Create(2048);
    CertificateRequest request = new(distinguishedName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    request.CertificateExtensions.Add(
        new X509KeyUsageExtension(
            X509KeyUsageFlags.DataEncipherment |
            X509KeyUsageFlags.KeyEncipherment |
            X509KeyUsageFlags.DigitalSignature,
            critical: false
        )
    );
    request.CertificateExtensions.Add(
        new X509EnhancedKeyUsageExtension(
            new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
            false
        )
    );
    request.CertificateExtensions.Add(sanBuilder.Build());
    X509Certificate2 certificate = request.CreateSelfSigned(
        new DateTimeOffset(DateTime.UtcNow.AddDays(-1)),
        new DateTimeOffset(DateTime.UtcNow.AddDays(3650))
    );
    const string password = "WeNeedASaf3rPassword";
    byte[] export = certificate.Export(X509ContentType.Pfx, password);
    X509Certificate2 result = X509CertificateLoader.LoadPkcs12(export, password);
    return result;
}

Host OS is Win11, this is where the certificate is generated and the API code runs.

tl;dr All programs in the container except mono or its csharp REPL are okay with the certificate and are able to make requests to my API. The certificate is apparently synced in the BTLS store that Mono uses.


Solution

  • Turns out BTLS is stricter than others about certificates' metadata.

    Starting from the fact that trust-self-signed.sh "https://self-signed.badssl.com:443" worked as expected, I modeled my certificate after the one used there to include organization & locale metadata, not have X509EnhancedKeyUsageExtension and X509KeyUsageExtension, worked like a charm.

    Ended up with this:

    public static X509Certificate2 BuildSelfSignedServerCertificate(string host)
    {
        using RSA rsa = RSA.Create(2048);
        CertificateRequest request = CreateRequest(rsa, host);
        X509Certificate2 certificate = CreateCertificate(request);
        return certificate;
    
        static CertificateRequest CreateRequest(RSA rsa, string host)
        {
            X500DistinguishedNameBuilder distinguishedName = new();
            distinguishedName.AddOrganizationName("Myself");
            distinguishedName.AddLocalityName("Sofia");
            distinguishedName.AddStateOrProvinceName("Sofia");
            distinguishedName.AddCountryOrRegion("BG");
            distinguishedName.AddCommonName(host);
            SubjectAlternativeNameBuilder sanExtension = new();
            sanExtension.AddDnsName(host);
            X509BasicConstraintsExtension constraintsExtension = new(
                certificateAuthority: false,
                hasPathLengthConstraint: false,
                pathLengthConstraint: 0,
                critical: false
            );
            CertificateRequest request = new(distinguishedName.Build(), rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
            request.CertificateExtensions.Add(sanExtension.Build());
            request.CertificateExtensions.Add(constraintsExtension);
            return request;
        }
    
        static X509Certificate2 CreateCertificate(CertificateRequest request)
        {
            X509Certificate2 certificate = request.CreateSelfSigned(
                new DateTimeOffset(DateTime.UtcNow.AddDays(-30)),
                new DateTimeOffset(DateTime.UtcNow.AddDays(365_0))
            );
            string password = $"{Guid.NewGuid():N}";
            byte[] export = certificate.Export(X509ContentType.Pfx, password);
            X509Certificate2 result = X509CertificateLoader.LoadPkcs12(export, password);
            return result;
        }
    }
    

    To compare a pair of certificates, one can use s_client -connect $HOST:$PORT to get it, save it as .crt and use the viewer built into Windows or some online viewer.

    enter image description here