Xamarin Forms (Android) 来自 KeyStore 与 PFX 文件的客户端证书

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

我在 Xamarin.Forms 应用程序(仅 android,没有 IOS 项目)中遇到客户端认证问题。 我有一个 .pfx 文件,我将其作为 EmbeddedResource 包含在我的解决方案中。 我还在我的 Android 11 设备上安装了这个 pfx,因此它出现在安全设置用户证书选项卡中。 这是一个完全有效的用户证书。

我想使用此客户端证书向后端发送 Post 请求。 当我使用我的解决方案中的 .pfx 文件时,它运行良好。 问题是当我从设备的密钥库中读取证书时我无法做同样的事情(我必须这样做,因为在生产中解决方案中不会有 .pfx)。

在这两种情况下,我都使用自定义 AndroidClientHandler,如您所见。

在第一个场景中,当我读取 .pfx 文件时,我在我的代码中的某处创建了 http 调用,如下所示:

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);

响应为 201 Created,所以一切正常。 魔法发生在 AndroidHttpsClientHandler class 中。 class 的完整代码是:

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;
  }
}

场景2:当我想使用设备安装的证书中的证书时,我用这个代码读取它:

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

var client = new AndroidHttpsClientHandler(clientCertByArray);

其余代码与场景 1 相同,但现在当 keyStore.Load(memoryStream, clientCertPassword.ToCharArray()) 在 Android 的构造函数中运行时,我得到一个 IOException HttpsClientHandler.

我怀疑pfxByteArray和clientCertByArray在这两种情况下不一样。

我们在 System.Security.Cryptography.X509Certificates 命名空间中有一个 X509Certificate2 class,它有一个 public X509Certificate2(byte[] rawData) 构造函数。 我将 pfxByteArray 和 clientCertByArray 传递给它以检查差异。

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

我注意到一个主要区别:notWorkingClientCert 实例的 PrivateKey 属性 为 null,而 HasPrivateKey 属性 为 false。

所以我的问题是如何以正确的方式从 KeyStore 中读取证书,就像在读取 .pfx 文件时一样?

我想提一下,这个代码 returns 对我来说是空的,但是证书的别名是“MyDeviceCert”:

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

经过 1 周的研究和不眠之夜,我终于弄明白了。我决定在这里post一个详细的答案,因为在网上找不到这个问题的确切答案。

所以第一种情况就是我在问题中描述的情况,当您拥有 .pfx 文件并且能够将其存储在您可以阅读的地方时。在这种情况下,您不必调用 KeyChain.GetPrivateKey 方法,因为 .pfx 文件包含私钥。 在 AndroidHttpsClientHandler 中,如果您有自己的 ca,则可以初始化自定义信任库。只需将此代码放在 class 的构造函数中,就在“sslContext = SSLContext.GetInstance("TLS")”行的正上方:

    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();
        }
    }

customCa 是一个您必须像 pfxByteArray 一样阅读的文件。而且你还必须像 keystoreRaw 变量一样将它传递给 AndroidHttpsClientHandler 的构造函数。 (我不需要那个代码,这就是问题中没有它的原因)

场景 2 - 当您必须从 Android 的用户证书存储中读取您的客户端证书时:

您喜欢在代码中的某处 post 像这样的数据:

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);

如您所见,HttpClient 使用 certificationService.GetAuthAndroidClientHander() 方法,该方法 return 是一个 Android 特定的 HttpClientHandler。 class存在于.Android项目中,不在shared中,代码如下:

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;
    }
}

这还没有完全优化,例如你不必阅读所有受信任的证书,但当它工作时我很高兴我不想改变它。

下一块是ClientCertificate class AndroidHttpsClientHander 的参数是什么。代码是(这也存在于 Android 项目中):

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

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

这个调用的属性是通过一个CertificationService来设置的class,什么是我的单例:

[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);
            }
        }
    }
}

它的接口(在共享项目中,因为我想到处使用):

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

PrivateKeyCallback 是一个 class 有一个方法,在用户 select 弹出窗口中的证书后将被触发(本答案稍后会详细介绍):

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();
        }
    }
}

拼图的最后一块是我创建 CertificationService 并使用它的方法的地方。我只是把 MainActivity 方法的这段代码放在第一行(Task.Run 为了不阻塞主线程):

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

而 RegisterCertificationService 只是 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);
    }
}

SetPrivateKeyFromUser 尝试获取私钥,如果失败并出现异常或保持为空,将要求用户选择一个。之后 KeyChain.GetPrivateKey 将有权 return 它。

请确定:MyConstants.DeviceCertAlias 是一个字符串,您证书的别名是什么(您可以在设置中的 Android 的用户证书存储中看到)。 请注意,CertificationService 是一个单例,因此每当我从 ioc 容器中获取时,我都会得到相同的实例。这就是为什么我的方法可以奏效。 另一个重要提示:我在平台特定项目中放置了 classes 内容。这很重要,因为我们需要达到 java 版本的 X509Certification(等)classes.

如果这个解决方案不适合你(我的证书不是自签名的)那么你应该检查这样的黑客:

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

一个重要提示:尝试以正确的格式获取认证,并使用 postman 进行测试: https://learning.postman.com/docs/sending-requests/certificates/

当“启用 SSL 证书验证”打开时,您应该可以使用它进行请求。如果它不能与验证一起工作,那么你可能最终会尝试破解 ServerCertificateCustomValidationCallback,这在 Xamarin 中的支持真的很差。尽量避免。