90

I was playing around with .NET Core and building an API that utilizes payment APIs. There's a client certificate that needs to be added to the request for two-way SSL authentication. How can I achieve this in .NET Core using HttpClient?

I have looked at various articles and found that HttpClientHandler doesn't provide any option to add client certificates.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Krishna.N
  • 1,123
  • 2
  • 9
  • 13

8 Answers8

74

I ran a fresh install for my platform (Linux Mint 17.3) following these steps: .NET Tutorial - Hello World in 5 minutes. I created a new console application targeting the netcoreapp1.0 framework, was able to submit a client certificate; however, I did receive "SSL connect error" (CURLE_SSL_CONNECT_ERROR 35) while testing, even though I used a valid certificate. My error could be specific to my libcurl.

I ran the exact same thing on Windows 7 and it worked exactly as needed.

// using System.Net.Http;
// using System.Security.Authentication;
// using System.Security.Cryptography.X509Certificates;

var handler = new HttpClientHandler();
handler.ClientCertificateOptions = ClientCertificateOption.Manual;
handler.SslProtocols = SslProtocols.Tls12;
handler.ClientCertificates.Add(new X509Certificate2("cert.crt"));
var client = new HttpClient(handler);
var result = client.GetAsync("https://apitest.startssl.com").GetAwaiter().GetResult();
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
  • 1
    woha it worked mate, Thank you so much taking time to help me. All these days I was off for vacation. – Krishna.N Dec 06 '16 at 11:07
  • I'm curious. Did you get this to work on a linux machine or windows? – Steven Jul 07 '17 at 01:22
  • I got it working on both Windows (10) and Linux (Mint 17.3, 18, and 18.1). I did get a "SSL connect error" (CURLE_SSL_CONNECT_ERROR 35) when I tried using a certificate without a private key. –  Jul 07 '17 at 14:38
  • @yfisaqt can you elaborate on how you saw the CURL error? I am finding I can sign with a cert on windows, but the same code on Ubuntu 17.04 is causing errors. – JosephGarrone Jul 14 '17 at 17:08
  • @JosephGarrone if my cert file was just the public key, I would get error 35, but only on Linux. If cert has the private key, the error would go away. In the Windows world, this could be a PFX file. –  Jul 16 '17 at 04:02
  • What does your cert.crt file look like? – Tom Oct 10 '17 at 23:01
  • 2
    @Tom To be technical, it depends on the file format. Keep in mind this was answered before I upgraded to .NET Core SDK 2, so this may behave a little differently in 2.0. At the time I answered this, Windows required the file to be a PFX but on Linux I was able to use a regular certificate (no private key). –  Oct 11 '17 at 17:54
  • I'm using .NET Core SDK 2 as well and having trouble creating a certificate to access an api that isn't SSL secure. Was hoping someone here could help. I need this to be able to run on Windows or Linux. – Tom Oct 11 '17 at 18:15
  • @Tom I would recommend asking a new question, explain what you've tried, what works, what doesn't, and what you're trying to do. –  Oct 11 '17 at 19:32
  • This code is still working in 2022 :) Only difference for me was that I had to reference a .pfx file instead of .cer – Yoismel Mar 22 '22 at 22:19
  • Unlikely. Too bad to use `var client = new HttpClient()` in 2022. Use IHttpClientFactory instead. – Niksr Nov 21 '22 at 20:34
35

I have a similar project where I communicate between services as well as between mobile and desktop with a service.

We use the Authenticode certificate from the EXE file to ensure that it's our binaries that are doing the requests.

On the requesting side (over simplified for the post).

Module m = Assembly.GetEntryAssembly().GetModules()[0];
using (var cert = m.GetSignerCertificate())
using (var cert2 = new X509Certificate2(cert))
{
   var _clientHandler = new HttpClientHandler();
   _clientHandler.ClientCertificates.Add(cert2);
   _clientHandler.ClientCertificateOptions = ClientCertificateOption.Manual;
   var myModel = new Dictionary<string, string>
   {
       { "property1","value" },
       { "property2","value" },
   };
   using (var content = new FormUrlEncodedContent(myModel))
   using (var _client = new HttpClient(_clientHandler))
   using (HttpResponseMessage response = _client.PostAsync($"{url}/{controler}/{action}", content).Result)
   {
       response.EnsureSuccessStatusCode();
       string jsonString = response.Content.ReadAsStringAsync().Result;
       var myClass = JsonConvert.DeserializeObject<MyClass>(jsonString);
    }
}

I then use the following code on the action that gets the request:

X509Certificate2 clientCertInRequest = Request.HttpContext.Connection.ClientCertificate;
if (!clientCertInRequest.Verify() || !AllowedCerialNumbers(clientCertInRequest.SerialNumber))
{
    Response.StatusCode = 404;
    return null;
}

We rather provide a 404 than a 500 as we like those that are trying URLs to get a bad request rather then let them know that they are "on the right track"

In .NET Core, the way to get the certificate is no longer by going over Module. The modern way that might work for you is:

private static X509Certificate2? Signer()
{
    using var cert = X509Certificate2.CreateFromSignedFile(Assembly.GetExecutingAssembly().Location);
    if (cert is null)
        return null;

    return new X509Certificate2(cert);
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Walter Verhoeven
  • 3,867
  • 27
  • 36
  • 1
    How do you confirm, that the client request is made with HTTP/1.1? There is no client certificate authentication in HTTP/2. – Jari Turkia Apr 24 '19 at 08:19
  • I know this is old, but to answer your comment: `handler.ServerCertificateCustomValidationCallback += (sender, cert, chain, sslPolicyErrors) => true;` – Michael Jun 03 '20 at 17:25
6

After a lot of testing with this issue I ended up with this.

  1. Using SSL, I created a pfx file from the certificate and key.
  2. Create a HttpClient as follows:
_httpClient = new(new HttpClientHandler
{
    ClientCertificateOptions = ClientCertificateOption.Manual,
    SslProtocols = SslProtocols.Tls12,
    ClientCertificates = { new X509Certificate2(@"C:\kambiDev.pfx") }
});
Toni
  • 1,555
  • 4
  • 15
  • 23
PanamaK
  • 61
  • 1
  • 2
4

I'm not using .NET for my client, but server side it can be configured simply via IIS by deploying my ASP.NET Core website behind IIS, configuring IIS for HTTPS + client certificates:

IIS client certificate setting:

Then you can get it simply in the code:

        var clientCertificate = await HttpContext.Connection.GetClientCertificateAsync();

        if(clientCertificate!=null)
            return new ContentResult() { Content = clientCertificate.Subject };

It's working fine for me, but I'm using curl or chrome as clients, not the .NET ones. During the HTTPS handshake, the client gets a request from the server to provide a certificate and send it to the server.

If you are using a .NET Core client, it can't have platform-specific code and it would make sense if it couldn't connect itself to any OS specific certificates store, to extract it and send it to the server. If you were compiling against .NET 4.5.x then it seems easy:

Using HttpClient with SSL/TLS-based client side authentication

It's like when you compile curl. If you want to be able to connect it to the Windows certificates store, you have to compile it against some specific Windows library.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Daboul
  • 2,635
  • 1
  • 16
  • 29
2

Can be used for both .NET Core 2.0< and .NET Framework 4.7.1<:

var handler = new HttpClientHandler();
handler.ClientCertificates.Add(new X509Certificate2("cert.crt"));
var client = new HttpClient(handler);

https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler?view=netframework-4.7.1

Ogglas
  • 62,132
  • 37
  • 328
  • 418
  • 1
    handler.ClientCertificates.Add(new X509Certificate2("cert.crt")) works only from Framework 4.8 (https://learn.microsoft.com/ru-ru/dotnet/api/system.net.http.httpclienthandler.clientcertificates?view=netframework-4.8#System_Net_Http_HttpClientHandler_ClientCertificates) – Kate Aug 17 '19 at 15:54
  • @Kate Miss from me that it did not work with `.Net Framework 4.5`< but the code works with `.Net Framework 4.7.1`. Tested it locally and documentation says so as well. Updated the answer. – Ogglas Aug 19 '19 at 06:08
  • Version specification seems to be reversed, I think it should read: Can be used for both .NET Core >2.0 and .NET Framework >4.7.1: Note inversion of sign(s). – Jonathan Aug 15 '22 at 07:06
2

Make all configuration in Main() like this:

public static void Main(string[] args)
{
    var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
    var logger = new LoggerConfiguration().ReadFrom.Configuration(configuration).CreateLogger();
    string env="", sbj="", crtf = "";

    try
    {
        var whb = WebHost.CreateDefaultBuilder(args).UseContentRoot(Directory.GetCurrentDirectory());

        var environment = env = whb.GetSetting("environment");
        var subjectName = sbj = CertificateHelper.GetCertificateSubjectNameBasedOnEnvironment(environment);
        var certificate = CertificateHelper.GetServiceCertificate(subjectName);

        crtf = certificate != null ? certificate.Subject : "It will after the certification";

        if (certificate == null) // present apies even without server certificate but dont give permission on authorization
        {
            var host = whb
                .ConfigureKestrel(_ => { })
                .UseContentRoot(Directory.GetCurrentDirectory())
                .UseIISIntegration()
                .UseStartup<Startup>()
                .UseConfiguration(configuration)
                .UseSerilog((context, config) =>
                {
                    config.ReadFrom.Configuration(context.Configuration);
                })
                .Build();
            host.Run();
        }
        else
        {
            var host = whb
                .ConfigureKestrel(options =>
                {
                    options.Listen(new IPEndPoint(IPAddress.Loopback, 443), listenOptions =>
                    {
                        var httpsConnectionAdapterOptions = new HttpsConnectionAdapterOptions()
                        {
                            ClientCertificateMode = ClientCertificateMode.AllowCertificate,
                            SslProtocols = System.Security.Authentication.SslProtocols.Tls12,
                            ServerCertificate = certificate
                        };
                        listenOptions.UseHttps(httpsConnectionAdapterOptions);
                    });
                })
                .UseContentRoot(Directory.GetCurrentDirectory())
                .UseIISIntegration()
                .UseUrls("https://*:443")
                .UseStartup<Startup>()
                .UseConfiguration(configuration)
                .UseSerilog((context, config) =>
                {
                    config.ReadFrom.Configuration(context.Configuration);
                })
                .Build();
            host.Run();
        }

        Log.Logger.Information("Information: Environment = " + env +
            " Subject = " + sbj +
            " Certificate Subject = " + crtf);
    }
    catch(Exception ex)
    {
        Log.Logger.Error("Main handled an exception: Environment = " + env +
            " Subject = " + sbj +
            " Certificate Subject = " + crtf +
            " Exception Detail = " + ex.Message);
    }
}

Configure file startup.cs like this:

#region 2way SSL settings
services.AddMvc();
services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = CertificateAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = CertificateAuthenticationDefaults.AuthenticationScheme;
})
.AddCertificateAuthentication(certOptions =>
{
    var certificateAndRoles = new List<CertficateAuthenticationOptions.CertificateAndRoles>();
    Configuration.GetSection("AuthorizedCertficatesAndRoles:CertificateAndRoles").Bind(certificateAndRoles);
    certOptions.CertificatesAndRoles = certificateAndRoles.ToArray();
});

services.AddAuthorization(options =>
{
    options.AddPolicy("CanAccessAdminMethods", policy => policy.RequireRole("Admin"));
    options.AddPolicy("CanAccessUserMethods", policy => policy.RequireRole("User"));
});
#endregion

The certificate helper

public class CertificateHelper
{
    protected internal static X509Certificate2 GetServiceCertificate(string subjectName)
    {
        using (var certStore = new X509Store(StoreName.Root, StoreLocation.LocalMachine))
        {
            certStore.Open(OpenFlags.ReadOnly);
            var certCollection = certStore.Certificates.Find(
                                       X509FindType.FindBySubjectDistinguishedName, subjectName, true);
            X509Certificate2 certificate = null;
            if (certCollection.Count > 0)
            {
                certificate = certCollection[0];
            }
            return certificate;
        }
    }

    protected internal static string GetCertificateSubjectNameBasedOnEnvironment(string environment)
    {
        var builder = new ConfigurationBuilder()
         .SetBasePath(Directory.GetCurrentDirectory())
        .AddJsonFile($"appsettings.{environment}.json", optional: false);

        var configuration = builder.Build();
        return configuration["ServerCertificateSubject"];
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Hamit YILDIRIM
  • 4,224
  • 1
  • 32
  • 35
1

I thought the best answer for this was provided here.

By utilizing the X-ARR-ClientCert header you can provide the certificate information.

An adapted solution is here:

X509Certificate2 certificate;
var handler = new HttpClientHandler {
    ClientCertificateOptions = ClientCertificateOption.Manual,
    SslProtocols = SslProtocols.Tls12
};
handler.ClientCertificates.Add(certificate);
handler.CheckCertificateRevocationList = false;
// this is required to get around self-signed certs
handler.ServerCertificateCustomValidationCallback =
    (httpRequestMessage, cert, cetChain, policyErrors) => {
        return true;
    };
var client = new HttpClient(handler);
requestMessage.Headers.Add("X-ARR-ClientCert", certificate.GetRawCertDataString());
requestMessage.Content = new StringContent(JsonConvert.SerializeObject(requestData), Encoding.UTF8, "application/json");
var response = await client.SendAsync(requestMessage);

if (response.IsSuccessStatusCode)
{
    var responseContent = await response.Content.ReadAsStringAsync();
    var keyResponse = JsonConvert.DeserializeObject<KeyResponse>(responseContent);

    return keyResponse;
}

And in your .net core server's Startup routine:

public IServiceProvider ConfigureServices(IServiceCollection services)
{
    services.AddCertificateForwarding(options => {
        options.CertificateHeader = "X-ARR-ClientCert";
        options.HeaderConverter = (headerValue) => {
            X509Certificate2 clientCertificate = null;
            try
            {
                if (!string.IsNullOrWhiteSpace(headerValue))
                {
                    var bytes = ConvertHexToBytes(headerValue);
                    clientCertificate = new X509Certificate2(bytes);
                }
            }
            catch (Exception)
            {
                // invalid certificate
            }

            return clientCertificate;
        };
    });
}
Michael Brown
  • 1,585
  • 1
  • 22
  • 36
  • 1
    X-ARR-ClientCert is an Azure-specific thing and this code does not actually authenticate that the client has the private key for the cert. It just attaches the public key the request. Note that the linked article has been corrected to actually use the client cert correctly. – Austin Feb 28 '21 at 19:32
  • This is relevant when proxy/load balancer does resend client certificate in X-Client-Cert header and ASP.NET Core app needs to use this certificate to create user ClaimsPrincipal from that certificate. Actually the default options are enough in that case and only this line is needed for setup: app.UseCertificateForwarding(); – drpdrp Aug 09 '23 at 15:46
0

If you look at the .NET Standard reference for the HttpClientHandler class, you can see that the ClientCertificates property exists, but is hidden due to the use of EditorBrowsableState.Never. This prevents IntelliSense from showing it, but will still work in code that uses it.

[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
public System.Security.Cryptography.X509Certificates.X509CertificateCollection ClientCertificates { get; }
Vivelin
  • 770
  • 1
  • 10
  • 23