简体   繁体   中英

Xamarin Forms (Android) Client certificate from KeyStore vs PFX file

I have an issue with client certification in a Xamarin.Forms app (android only, no IOS project). I have a.pfx file what I included in my solution as EmbeddedResource. I also installed this pfx on my Android 11 device, so it appeared in the security settings user certificates tab. This a fully valid user cert.

I would like to use this client certificate in order to do Post requests to the backend. When I use the.pfx file from my solution then it's working perfectly. The problem is that I'm unable to do the same when I read the certificate from the device's keystore (and I have to do that way, because in production there will be no.pfx in the solution).

In both scenario I'm using a custom AndroidClientHandler as you will see.

In the first scenario when I read the.pfx file I create the http call somewhere in my code like this:

var ms = new MemoryStream();
Assembly.GetExecutingAssembly().GetManifestResourceStream("CertTest.MyDeviceCert.pfx").CopyTo(ms);
var pfxByteArray = ms.ToArray();

string url = @"https://my-backend-hostname:443/api/endpoint-name";

var objectToPost = someObjectWhatIWantToPost.

var client = new AndroidHttpsClientHandler(pfxByteArray);

var httpClient = new HttpClient(client);

var request = new HttpRequestMessage(HttpMethod.Post, url);

request.Content = JsonContent.Create(objectToPost);

var response = await httpClient.SendAsync(request);

The response is 201 Created, so everything is fine. The magic happens in the AndroidHttpsClientHandler class. The full code of the class is:

public class AndroidHttpsClientHandler : AndroidClientHandler
{
  private SSLContext sslContext;
  private const string clientCertPassword = "123456";
        
  public AndroidHttpsClientHandler(byte[] keystoreRaw) : base()
  {
    IKeyManager[] keyManagers = null;
    ITrustManager[] trustManagers = null;

    if (keystoreRaw != null)
    {
      using (MemoryStream memoryStream = new MemoryStream(keystoreRaw))
      {
        KeyStore keyStore = KeyStore.GetInstance("pkcs12");
        keyStore.Load(memoryStream, clientCertPassword.ToCharArray());
        KeyManagerFactory kmf = KeyManagerFactory.GetInstance("x509");
        kmf.Init(keyStore, clientCertPassword.ToCharArray());
        keyManagers = kmf.GetKeyManagers();
      }
    }

    sslContext = SSLContext.GetInstance("TLS");
    sslContext.Init(keyManagers, trustManagers, null);
  }
        
  protected override SSLSocketFactory ConfigureCustomSSLSocketFactory(HttpsURLConnection 
  connection)
  {
    SSLSocketFactory socketFactory = sslContext.SocketFactory;
    if (connection != null)
    {
      connection.SSLSocketFactory = socketFactory;
    }
    return socketFactory;
  }
}

Scenario2: When I would like to use the certificate from the Device's installed certificates, I read it with this code:

var keyChain = KeyChain.GetCertificateChain(Android.App.Application.Context, alias);
var clientCert = keyChain.FirstOrDefault();
var clientCertByArray = clientCert.GetEncoded();

var client = new AndroidHttpsClientHandler(clientCertByArray);

And the rest code is the same as Scenario1, but now I get an IOException when the keyStore.Load(memoryStream, clientCertPassword.ToCharArray()) runs in the ctor of the AndroidHttpsClientHandler.

I suspected that the pfxByteArray and the clientCertByArray was not the same in the two scenarios.

We have a X509Certificate2 class in the System.Security.Cryptography.X509Certificates namespace, what has a public X509Certificate2(byte[] rawData) constructor. I passed the pfxByteArray and the clientCertByArray to it in order to check the differences.

var workingCert = new X509Certificate2(pfxByteArray);
var notWorkingClientCert = new X509Certificate2(clientCertByArray);

I noticed one mayor difference: the notWorkingClientCert instace's PrivateKey property is null, and the HasPrivateKey property is false.

So my question is how can I read the certificate from the KeyStore in the proper way like when I'm reading a.pfx file?

Something I would like to mention, this code returns null to me, but the alias of the certificate is "MyDeviceCert":

var privateKey = KeyChain.GetPrivateKey(Android.App.Application.Context, "MyDeviceCert");

Answer by @szg1993 is almost right or might be correct depending upon the version and usage.

Some changes I made to his answer to get it working for me:

  • from .NET 6 AndroidClientHandler is deprecated. Use AndroidMessageHandler, which works almost the same. This would also mean to return HttpMessageHandler instead of HttpClientHandler and then use that to configure HttpClient.
  • I have also configured SSL using SSLContext function given below and added list of keyManager and trustManager (if required) and also implemented ConfigureCustomSSLSocketFactory
      private SSLContext GetSSLContext() {
           string protocol;
           if (SslProtocols == SslProtocols.Tls11)
           {
                protocol = "TLSv1.1";
           }
           else if (SslProtocols == SslProtocols.Tls || SslProtocols == SslProtocols.Tls12)
           {
                protocol = "TLSv1.2";
           }
           else
           {
                throw new IOException("unsupported ssl protocol: " + SslProtocols.ToString());
           }
    
            IKeyManager keyManager;
            # KeyChainKeyManager is a custom class
            keyManager = KeyChainKeyManager.fromAlias(Application.Context, mAlias);
    
            SSLContext ctx = SSLContext.GetInstance("TLS");
            ctx.Init(new IKeyManager[] { keyManager }, null, null);
            return ctx;
      }

      protected override SSLSocketFactory ConfigureCustomSSLSocketFactory(HttpsURLConnection connection)
      {
            SSLSocketFactory socketFactory = sslContext.SocketFactory;
            if (connection != null)
            {
                connection.SSLSocketFactory = socketFactory;
            }
            return socketFactory;
      }

KeyChainKeyManager class extends X509ExtendedKeyManager and

   returns new KeyChainKeyManager(alias, certificateChain, privateKey);

And finally I used the interface as

    ICertificationService certificationService = DependencyService.Get<ICertificationService>();
    var httpClient = new HttpClient(certificationService.GetAuthAndroidClientHander());

After 1 week of research and sleepless nights I finally figured it out. I decided to post here a detailed answer, because the exact answer for this problem cannot be found on the inte.net.

So the first scenario is that one what I described in the question, when you have the.pfx file and you are able to store it somewhere where you can read it. In that scenario you don't have to call the KeyChain.GetPrivateKey method, because the.pfx file contains the private key. In the AndroidHttpsClientHandler you can init a custom truststore if you have your own ca. Just put this code in the ctor of the class, right above the "sslContext = SSLContext.GetInstance("TLS")" line:

    if (customCA != null)
    {
        CertificateFactory certFactory = CertificateFactory.GetInstance("X.509");

        using (MemoryStream memoryStream = new MemoryStream(customCA))
        {
            KeyStore keyStore = KeyStore.GetInstance("pkcs12");
            keyStore.Load(null, null);
            keyStore.SetCertificateEntry("MyCA", certFactory.GenerateCertificate(memoryStream));
            TrustManagerFactory tmf = TrustManagerFactory.GetInstance("x509");
            tmf.Init(keyStore);
            trustManagers = tmf.GetTrustManagers();
        }
    }

The customCa is a file what you have to read like the pfxByteArray. And also you have to pass it to the ctor of the AndroidHttpsClientHandler like the keystoreRaw variable. (I don't need that code, this is the reason why it not present in the question)

Scenario 2 - when you have to read your client certificate from the Android's user cert store:

Somewhere in your code you like to post data something like this:

var httpClient = new HttpClient(certificationService.GetAuthAndroidClientHander());

var request = new HttpRequestMessage(HttpMethod.Post, url);

request.Content = new StringContent(JsonConvert
.SerializeObject(objectToPost), Encoding.UTF8, "application/json");

var response = await httpClient.SendAsync(request);

As you can see the HttpClient uses the certificationService.GetAuthAndroidClientHander() method which returns an Android specific HttpClientHandler. The class lives in the.Android project, not in the shared, and the code is the following:

public class AndroidHttpsClientHander : AndroidClientHandler
{
    private readonly ClientCertificate clientCertificate;

    public AndroidHttpsClientHander(ClientCertificate clientCertificate)
    {
        this.clientCertificate = clientCertificate;

        var trustManagerFactory = TrustManagerFactory
            .GetInstance(TrustManagerFactory.DefaultAlgorithm);

        trustManagerFactory.Init((KeyStore)null);

        var x509trustManager = trustManagerFactory
            .GetTrustManagers()
            .OfType<IX509TrustManager>()
            .FirstOrDefault();

        var acceptedIssuers = x509trustManager.GetAcceptedIssuers();

        TrustedCerts = clientCertificate.X509CertificateChain
            .Concat(acceptedIssuers)
            .ToList<Certificate>();
    }

    protected override KeyStore ConfigureKeyStore(KeyStore keyStore)
    {
        keyStore = KeyStore.GetInstance("PKCS12");

        keyStore.Load(null, null);

        keyStore.SetKeyEntry("privateKey", clientCertificate.PrivateKey,
            null, clientCertificate.X509CertificateChain.ToArray());

        if (TrustedCerts?.Any() == false)
            return keyStore;

        for (var i = 0; i < TrustedCerts.Count; i++)
        {
            var trustedCert = TrustedCerts[i];
            
            if (trustedCert == null)
                continue;

            keyStore.SetCertificateEntry($"ca{i}", trustedCert);
        }

        return keyStore;
    }

    protected override KeyManagerFactory ConfigureKeyManagerFactory(KeyStore keyStore)
    {
        var keyManagerFactory = KeyManagerFactory.GetInstance("x509");
        keyManagerFactory.Init(keyStore, null);
        return keyManagerFactory;
    }

    protected override TrustManagerFactory ConfigureTrustManagerFactory(KeyStore keyStore)
    {
        var trustManagerFactory = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm);
        trustManagerFactory.Init(keyStore);
        return trustManagerFactory;
    }
}

This is not fully optimized, for example you don't have to read all the trusted certs, but I was so happy when it worked that I don't wan't to change it.

The next piece is the ClientCertificate class what is the parameter for the AndroidHttpsClientHander. The code is (this also lives in the Android project):

public class ClientCertificate
{  
    public IPrivateKey PrivateKey { get; set; }

    public IReadOnlyCollection<X509Certificate> X509CertificateChain { get; set; }
}

The properties of this call are setted by a CertificationService class, what is a singleton of mine:

[assembly: Dependency(typeof(CertificationService))]
namespace MyProject.Droid.Services
{
    public class CertificationService : ICertificationService
    {
        public ClientCertificate ClientCertificate { get; set; }

        private readonly ILoggerService loggerService;

        public CertificationService()
        {
            ClientCertificate = new ClientCertificate();
            loggerService = Startup.ServiceProvider.GetRequiredService<ILoggerService>();
        }

        public void SetPrivateKeyFromUser(object activity)
        {
            SetPrivateKey();

            if (ClientCertificate.PrivateKey != null)
                return;

            KeyChain.ChoosePrivateKeyAlias(
                activity: (Activity)activity,
                response: new PrivateKeyCallback(this),
                keyTypes: new string[] { "RSA" },
                issuers: null,
                uri: null,
                alias: MyConstants.DeviceCertAlias);
        }

        public void SetCertificateChain()
            => ClientCertificate.X509CertificateChain = KeyChain
                .GetCertificateChain(Android.App.Application.Context, MyConstants.DeviceCertAlias);

        public HttpClientHandler GetAuthAndroidClientHander()
            => new AndroidHttpsClientHander(ClientCertificate);

        public string GetCertificationDetailsError()
        {
            if (ClientCertificate?.X509CertificateChain == null)
                return $"{LogMessages.CertificationChainEmpty}";

            if (ClientCertificate?.PrivateKey == null)
                return $"{LogMessages.PrivateKeyIsNull}";

            if (string.IsNullOrEmpty(ClientCertificate?.CN))
                return $"{LogMessages.DeviceCnIsNull}";

            return null;
        }

        public void SetPrivateKey()
        {
            try
            {
                ClientCertificate.PrivateKey = KeyChain
                    .GetPrivateKey(Android.App.Application.Context, MyConstants.DeviceCertAlias);
            }
            catch (Exception e)
            {
                loggerService.LogError(e);
            }
        }
    }
}

It's interface (in the shared project, because I want to use everywhere):

namespace MyProject.Services
{
    public interface ICertificationService
    {  
        void SetPrivateKeyFromUser(object activity);
        void SetCertificateChain();
        HttpClientHandler GetAuthAndroidClientHander();
        string GetCertificationDetailsError();
        void SetPrivateKey();
    }
}

The PrivateKeyCallback is a class what has a method which will be fired, after the user select the certificate from the popup (more on that later in this answer):

namespace MyProject.Droid.Utils
{
    public class PrivateKeyCallback : Java.Lang.Object, IKeyChainAliasCallback
    {
        private readonly ICertificationService certificationService;

        public PrivateKeyCallback(ICertificationService certificationService)
            => this.certificationService = certificationService;

        public void Alias(string alias)
        {
            if (alias != MyConstants.DeviceCertAlias)
                return;

            certificationService.SetPrivateKey();
        }
    }
}

And the final piece of the puzzle is the place where I create the CertificationService and use it's methods. I just put this code of the MainActivity's method as it's first line (Task.Run in order to not block the main thread):

Task.Run(() => RegisterCertificationService());

And the RegisterCertificationService is just a private method in the MainActivity:

private void RegisterCertificationService()
{
    var certificationService = new CertificationService();

    try
    {
        certificationService.SetPrivateKeyFromUser(this);
        certificationService.SetCertificateChain();

        DependencyService.RegisterSingleton<ICertificationService>(certificationService);
    }
    catch (Exception e)
    {
        Startup.ServiceProvider
            .GetRequiredService<ILoggerService>()
            .LogError(e);
    }
}

The SetPrivateKeyFromUser tries to get the private key and if it fail with exception or stays null the user will be asked to choose one. After that the KeyChain.GetPrivateKey will has permission to return it.

Just to be sure: MyConstants.DeviceCertAlias is a string, what is the alias of you certificate (what you can see in the Android's user cert store in the settings). Note that the CertificationService is a singleton, so whenever I get the from the ioc container I got the same instance. This is why my way can work. Another important tip: there are classes what I placed in the plarform specific project. That is important, because we need to reach the java version of the X509Certification (etc) classes.

If this solution not works for you (my certs are not self signed) then you should probably check hacks like this:

yourHttpClientHandler.ServerCertificateCustomValidationCallback +=
    (sender, cert, chain, sslPolicyErrors) =>
    {
        return true;
    };

One important tip: Try to get the certification in a proper format and use it with postman to test it first: https://learning.postman.com/docs/sending-requests/certificates/

You should be able to do requests with it when the "Enable SSL certificate validation" is turned ON. If it's not working with verification ON, than you probably will end up trying hack the ServerCertificateCustomValidationCallback, which is really poorly supported in Xamarin. Try to avoid it.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM