Getting ASP.NET Core dev certs working in both WSL and Windows

I recently re-installed my development machine for different reasons, and as part of that, I decided that I was going to use Visual Studio Code and the Remote - WSL extension to do my development in Linux for a while.

Warning: For some reason the code in this post does not work anymore. It did while I was writing the post, but when I tried to use it recently, it failed. I’m not sure if there have been some changes in .NET 5 or something. But it’s not working for me. And because of this I would recommend reading my updated post on the topic. It’s available at “Setting up ASP.NET Core dev certs for both WSL and Windows”!

Why? Well, there are some benefits to it… And also… Because!

I could probably keep going in the Windows world without too much problems, and I probably will for some stuff. But seeing that I have been in Windows my entire life, and .NET Core supports Linux, I thought it was time to broaden my horizon a bit. I find that challenging myself to try new things, tend to not only give me a better understanding of things, but also open my eyes up to new ways of doing things. Maybe Windows isn’t the best solution for everything…

However, I very quickly ran into some problems. Certificate problems to be more precise…

Dev certs on WSL

When you create an ASP.NET Core application, and you want to use HTTPS, you “need” to a self-signed development certificate. This is fairly easy to get set up using the dotnet dev-certs tool. And to be honest, when I set up my new application in WSL, I didn’t even need to do that. It just magically worked. Kestrel somehow managed to find an SSL cert in Linux, and used that for my application. However, this cert isn’t trusted by Windows (or Linux), so my browser gave me a warning when I browsed to that web app.

This is fairly easy to fix by just having Windows trust the SSL certificate. And it is covered in a bunch of other blog posts and SO questions around the interwebs. However, this doesn’t sort out some other issues that came up half an hour later…

Dev cert trust on WSL

My real problems started when I decided to build two applications that needed to communicate with each other using TLS/SSL… I needed to build an IdP using Identity Server, and have a web application trust it.

Spinning up the IdP using dotnet run in WSL worked fine, and I could browse to it without a problem from Windows. However, when I started up my second application inside WSL, the one that relied on the IdP, I was faced with

...
---> System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.
---> System.Security.Authentication.AuthenticationException: The remote certificate is invalid because of errors in the certificate chain: UntrustedRoot
     at System.Net.Security.SslStream.SendAuthResetSignal(ProtocolToken message, ExceptionDispatchInfo exception)
...

The reason for this is actually pretty simple… The SSL certificate being used for the ASP.NET applications by default, is not trusted by the system, even if it is a self-signed cert from the local machine. So when the relying party web application tries to talk to the IdP, it gets served an SSL certificate that it doesn’t trust. And thus, it fails.

There are a couple of ways to solve this. The simplest, and probably least recommended one is to ignore the cert issue by adding something like this

...
#if DEBUG
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => { return true; };
#endif
...
#if DEBUG
options.BackchannelHttpHandler = handler;
#endif
...

This adds an http handler that ignores certificate validation completely, which is obviously not a great idea. Even if it only uses that code during debug builds.

Sure, it works, but it is a poor solution that requires code changes to every project. And if you for some reason happen to release a debug build to the world, a lot of your security has just gone out the window…

The better solution would be to trust the certificate.

Trusting dev certs on Linux (Ubuntu)

There is apparently no standard way to trust certs on Linux, but on Ubuntu, you can trust a cert by placing the public key in /usr/local/share/ca-certificates and then call sudo update-ca-certificates. However…this doesn’t quite work with the certificates generated by the dotnet dev-certs tool.

Why? Well, there is apparently a bug in OpenSSL that causes some issues. It has to do with Linux requiring some X509v3 attributes that Windows doesn’t, and so on. It’s a very technical topic to be honest, and a bit boring… But if you are interested, you can read more about it at https://github.com/dotnet/aspnetcore/issues/7246.

So, instead we have to generate a certificate manually, and use that instead. This is also covered in a few places on the internet, but I want to take it a little bit further. I want to make sure that I can use the same certificate in both Windows and WSL. For two reasons. Firstly, because it should be possible… And secondly, if you use separate certs, you won’t be able to mix applications in WSL and Windows, as the WSL ones would not trust the cert from Windows unless you added trust to that cert as well. And having a single trusted cert that is used in both environment would be much nicer.

That pulled me down a bit of a rabbit hole that has taken me quite some time to figure out. Not being the best at OpenSSL, together with me being fairly new at Linux, as well not knowing what .NET Core sees as a valid developer certificate, made this a little bit more complicated than I had expected to be honest. But this is what I came up with.

Creating a X509 cert

The first thing we need to do is to create a new X509 certificate. A task I had hoped that I could do using PowerShell and New-SelfSignedCertificate. However, that didn’t work out… Why? Well, for .NET Core to see a cert as a dev cert, it needs to include a specific X509 extension that I couldn’t get the PowerShell command to add. So instead, I had to use OpenSSL in WSL to create it.

The first thing I needed, to create a new cert, was a config file that defined the certificate requirements. So I created a file called localhost.conf in my home directory, and added the following

 [req]
distinguished_name = req_distinguished_name
req_extensions     = req_ext
x509_extensions    = v3_ca

[req_distinguished_name]
commonName                  = Common Name (e.g. server FQDN or YOUR name)
commonName_default          = localhost
commonName_max              = 64

[req_ext]
subjectAltName = @alt_names
1.3.6.1.4.1.311.84.1.1=ASN1:UTF8String:Something

[v3_ca]
subjectAltName = @alt_names
basicConstraints = critical, CA:false
keyUsage = keyCertSign, cRLSign, digitalSignature,keyEncipherment

[alt_names]
DNS.1   = localhost
DNS.2   = 127.0.0.1

This will create a new cert with a 2048 bit key, including the required key usages.

Note: Linux apparently requires self-singed certs to be both a cert and a CA, so it needs cert signing usage assigned to it.

On top of the correct key usages, it adds a custom extension with the ID 1.3.6.1.4.1.311.84.1.1. This is used by ASP.NET Core to validate that this is truly a valid dev certificate. It was also the thing I couldn’t get PowerShell to add for me…

Once the config is in place, it can be used to create a public/private key pair using the following command

openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ~/localhost.key -out ~/localhost.crt -config ~/localhost.conf

Note: Just press enter when it asks for Common Name as it defaults to localhost, which is what you want

Just to verify that the public key isn’t trusted yet, we can run

> openssl verify ~/localhost.crt

CN = localhost
error 18 at 0 depth lookup: self signed certificate
error /home/zerokoll/localhost.crt: verification failed

The next step is to make sure that Ubuntu trusts the certificate. To do this, the public key needs to be copied to /usr/local/share/ca-certificates and then trusted as a CA by running update-ca-certificates. Like this

sudo cp ~/localhost.crt /usr/local/share/ca-certificates
sudo update-ca-certificates

Now, we can once again try to verify that the cert is trusted by running openssl verify

openssl verify ~/localhost.crt

/home/zerokoll/localhost.crt: OK

Ok, so now the Linux part trusts the certificate. The next step is to get Windows to do the same.

Windows needs a PFX version of the cert, that includes both the private and public key. The reason that it needs both is that we aren’t just going to trust it, we are going to use it as an SSL cert on that side as well. So the first step is to create a PFX from the public/private key pair. Like this

openssl pkcs12 -export -out ~/localhost.pfx -inkey ~/localhost.key -in ~/localhost.crt

This will ask for a password, and you should make sure you add a strong password as it could be dangerous if someone nefarious got hold of it. Remember, it is a CA-cert and you are trusting it. So if anyone got hold of it, used it to generate a new cert, you would automatically trust that cert…

Next, the PFX needs to be moved to Windows. Actually, it could stay in Linux as well, but moving it to Windows allows it to be easily used from Windows, as well as copied into other Linux distros if needed.

Moving the file to Windows looks like this

mkdir -p /c/Users/<USERNAME>/.aspnet/.ssl
mv ~/localhost.pfx /c/Users/<USERNAME>/.aspnet/https

Where [USERNAME] is the name of your Windows user.

Note: I mount my Windows drives to the root of my WSL. If you don’t, you need to change the path to /mnt/c/Users/<USERNAME>/.aspnet/https

Once that is done, The public/private keys etc can be deleted as they aren’t needed anymore

rm -rf ./localhost.*

The last part in getting mutual trust set up, is to import the newly created certificate as a dev-cert, and trust it, using dotnet dev-certs. I used PowerShell to do it like this

dotnet dev-certs https --clean --import (Join-Path $env:UserProfile ".aspnet/https/localhost.pfx") -p <PASSWORD>
dotnet dev-certs https --trust

That’s it! Both systems should now use and trust the same certificate.

On the Windows side, Kestrel should pick up the cert automatically as it is available in the current users certificate person store.

Unfortunately, the WSL side of things isn’t quite that nice… It needs some configuration to make sure that the correct cert is used by default.

Configuring Kestrel in WSL to use the right SSL certificate

Kestrel can be configured to use a X509 cert by setting the ASPNETCORE_Kestrel:Certificates:Default:Path and ASPNETCORE_Kestrel:Certificates:Default:Password configuration values. This can be done in several ways.

One way would be to set up the configuration in the appSettings or user secrets. But it would be required for every project you worked on, which feels tedious and annoying.

Another option is to set up environment variables that defines these values. This would automatically get picked up as a default value by any application running in WSL.

In my case, i use zsh as my shell, so I opened up ~/.zshrc and added

export ASPNETCORE_Kestrel__Certificates__Default__Password="<PASSWORD>"
export ASPNETCORE_Kestrel__Certificates__Default__Path="/c/Users/<USERNAME>/.aspnet/https/localhost.pfx"

Note: When setting config values as environment variables, you need to replace : with double underscores.

This will make sure that those environment variables are set up whenever this shell is used.

I then reloaded the variables by running source ~/.zshrc.

Comment: If you use bash, you could add it to for example ~/.profile instead.

That should be it! Well…ish…

Caveat…IIS Express

There is one problem with this solution… IIS Express won’t play nice unfortunately. The previous solution will set it up so that Kestrel will find the new certificate, IIS Express on the other hand, still wants to use its own self-signed cert. So, if you want to use IIS Express for your development, you will need to tell it to use your cert.

Note: It isn’t really IIS Express. It is actually HTTP.SYS that binds the ports and certs and so on. And then IIS Express sits on top of that…

By default, IIS Express (or maybe Visual Studio, not sure) seems to bind ports 44300 to 44399 to it’s own self-signed certificate. This causes some problems when you want

to use your own cert. Or, if you happen to delete the IIS Express self-signed cert. Don’t ask me how I know…

You can see the current SSL port bindings by running

netsh http show sslcert

And if you look through that list, you will likely see that all the previously mentioned ports are preemptively bound to the same IIS Express dev-cert even if they aren’t actually in use yet.

So if you want to use your own cert instead for one of those ports, you need do 2 things.

First, you need to add your own self-signed cert to the local machine’s certificate store, in the Personal > Certificates folder. Or run the following commands in an elevated PowerShell terminal


$mypwd = Get-Credential -UserName 'Enter password below' -Message 'Enter password below'
Import-PfxCertificate -FilePath <PATH_TO_PFX> -CertStoreLocation Cert:\LocalMachine\My -Password $mypwd.Password

Next, you need to bind your new cert to the port in question. This is not that hard to do, as long as you have the thumbprint of the cert. Something you can easily get from the certificate MMC snap-in, or by running a PowerShell command that looks something like this

Write-Host (Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object {$_.Subject -match "CN=localhost"}).ThumbPrint

Tip: If you get more than one thumbprint, you probably have both your own self-signed cert and IIS Express one in there.

Once you have the thumbprint, you need to bind the port you want to use, using netsh. It looks like this

netsh http update sslcert ipport=0.0.0.0:<PORT> appid='{<APPID>}' certhash=<CERT_HASH>

Where PORT is the port you want to use, APPID is any GUID you want and CERT_HASH is the thumbprint you just retrieved.

Another option is to use IisExpressAdminCmd.exe to do the work. This is a little nicer as it doesn’t care GUIDs. All you need to do, is to navigate to C:\Program Files (x86)\IIS Express and run IisExpressAdminCmd.exe. Like this

cd "C:\Program Files (x86)\IIS Express"

.\IisExpressAdminCmd.exe setupsslUrl -url:https://localhost:<PORT>/ -CertHash:<THUMBPRINT>

Where THUMBPRINT is the thumbprint for your certificate, and PORT is the port that is being used for your application.

Both options end up with exactly the same result. And you can verify that the result is what you want by running

netsh http show sslcert ipport=0.0.0.0:<PORT>

This should show you that the cert you want to use is now bound to that port.

Unfortunately, this needs to be done for any applications that you want to run through IIS Express, which is a bit tedious.

So, if you want to make sure you get the same experience as you normally would, which is that you spin up Visual Studio, start a new project, press F5, and the cert just works, you can execute the following.

For ($i=0; $i -le 99; $i++) {
   .\IisExpressAdminCmd.exe setupsslUrl -url:"https://localhost:443$($i.ToString().PadLeft(2, "0"))/" -CertHash:<THUMBPRINT>
}

This will run through all the pre-bound ports (44300 -> 44399) and re-bind them all to your own cert. This should make everything “just work”, allowing you to transparently use your new self-signed cert in IIS Express.

Note: Personally, I prefer to run the application through the terminal window instead, as it allows me to view the log output easily. But having that said, IIS Express is still a very valid option!

Conclusion

Running applications in WSL should now use our custom cert for SSL by default. So should any application running using either Kestrel or IIS Express in Windows. On top of that, the cert should be trusted by both WSL/Linux and Windows, allowing for trusted server to server communication between applications in WSL, as well as between apps in WSL and Windows. And with Windows trusting the cert, it should also allow you to browse to your HTTPS-enabled applications and get an approved, secure connection to applications running in both WSL and Windows.

It was a bit of hassle to figure out, but now that it is up and running, it should be done and dusted, and I shouldn’t have to care anymore!

If you have any comments or questions, feel free to reach out at @ZeroKoll!

zerokoll

Chris

Developer-Badass-as-a-Service at your service